Compare commits
157 Commits
eff33dfe09
...
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 |
34
.package
Normal file
34
.package
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
required = {
|
||||||
|
'platform',
|
||||||
|
},
|
||||||
|
title = "RemoteTurtle",
|
||||||
|
description = "Web-based remote control for CC:Tweaked turtles with 3D visualization, D* Lite pathfinding, and state-machine AI. Includes turtle controller, GPS host, web bridge, and pocket computer programs.",
|
||||||
|
repository = "gitea://git.spatulaa.com/MayaTheShy/remoteturtle/master/",
|
||||||
|
exclude = {
|
||||||
|
"^server/", "^client/", "^__tests__/",
|
||||||
|
"^startup_", "^start%.",
|
||||||
|
"%.md$", "%.yml$", "%.json$", "%.bat$", "%.sh$",
|
||||||
|
"^Dockerfile", "^%.git", "^LICENSE$", "^node_modules/",
|
||||||
|
},
|
||||||
|
install = [[
|
||||||
|
local pkgDir = fs.combine("packages", "remoteturtle")
|
||||||
|
|
||||||
|
-- Web Bridge config
|
||||||
|
print("")
|
||||||
|
print("-- RemoteTurtle Web Bridge Setup --")
|
||||||
|
print("")
|
||||||
|
write("Server URL (e.g. http://192.168.1.10:4200): ")
|
||||||
|
local serverUrl = read()
|
||||||
|
if serverUrl and #serverUrl > 0 then
|
||||||
|
local wsUrl = serverUrl:gsub("^http", "ws") .. "/ws/bridge"
|
||||||
|
local cfg = textutils.serialiseJSON({ serverUrl = serverUrl, wsUrl = wsUrl })
|
||||||
|
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||||
|
f.write(cfg)
|
||||||
|
f.close()
|
||||||
|
print("Saved web bridge config.")
|
||||||
|
else
|
||||||
|
print("Skipped — edit .webbridge_config later.")
|
||||||
|
end
|
||||||
|
]],
|
||||||
|
}
|
||||||
@@ -64,6 +64,36 @@ You should see:
|
|||||||
**GPS not working?**
|
**GPS not working?**
|
||||||
- Set up 4 GPS host computers at high altitude
|
- Set up 4 GPS host computers at high altitude
|
||||||
- Run `gps host X Y Z` on each (with their coordinates)
|
- Run `gps host X Y Z` on each (with their coordinates)
|
||||||
|
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||||
|
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||||
|
|
||||||
|
## Pathfinding
|
||||||
|
|
||||||
|
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||||
|
You can trigger it from the web UI via eval commands:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Navigate to coordinates (avoids obstacles)
|
||||||
|
_pathfind.goto(100, 65, -200)
|
||||||
|
|
||||||
|
-- Navigate with block digging enabled
|
||||||
|
_pathfind.goto(100, 65, -200, { dig = true })
|
||||||
|
|
||||||
|
-- Go home
|
||||||
|
_pathfind.goHome()
|
||||||
|
|
||||||
|
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||||
|
_pathfind.face(3)
|
||||||
|
|
||||||
|
-- Get current heading name
|
||||||
|
_pathfind.headingName() -- "east"
|
||||||
|
```
|
||||||
|
|
||||||
|
The pathfinder:
|
||||||
|
- Uses GPS for initial position, then tracks movement locally
|
||||||
|
- Auto-detects heading on startup via GPS triangulation
|
||||||
|
- Handles obstacles by trying to go over/around them
|
||||||
|
- Supports optional block digging for clearing paths
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|||||||
@@ -4,37 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
background: #2c2c2c;
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-bottom: 2px solid #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button:hover {
|
|
||||||
background: #475569;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
@@ -57,21 +27,13 @@
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content.map .panel-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.panel .map-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0a0e1a;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -81,34 +43,62 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 2px solid #334155;
|
border-bottom: 3px solid #1a1a1a;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button {
|
.panel-tabs button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
background: #334155;
|
background: #5a5a5a;
|
||||||
color: #94a3b8;
|
color: #b0b0b0;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button:hover {
|
.panel-tabs button:hover {
|
||||||
background: #475569;
|
background: #6b6b6b;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button.active {
|
.panel-tabs button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Cross-link Button === */
|
||||||
|
.cross-link-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
background: #2e4a8b;
|
||||||
|
color: #55ffff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-link-btn:hover {
|
||||||
|
background: #3e5a9b;
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-content-wrapper {
|
.panel-content-wrapper {
|
||||||
@@ -125,16 +115,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1e293b;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #475569;
|
background: #5a5a5a;
|
||||||
border-radius: 4px;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #64748b;
|
background: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-Responsive Design */
|
/* Mobile-Responsive Design */
|
||||||
@@ -149,28 +139,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Mobile: Force single view mode with tabs */
|
|
||||||
.view-controls {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.split {
|
.app-content.split {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -191,17 +162,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* Small mobile: Optimize spacing */
|
|
||||||
.view-controls {
|
|
||||||
padding: 0.5rem;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 0.625rem 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.split .map-container {
|
.app-content.split .map-container {
|
||||||
height: 40vh;
|
height: 40vh;
|
||||||
}
|
}
|
||||||
@@ -270,10 +230,6 @@
|
|||||||
|
|
||||||
/* High contrast mode */
|
/* High contrast mode */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
.view-controls button {
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.turtle-card {
|
.turtle-card {
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Map3D from './components/Map3D';
|
import Map3D from './components/Map3D';
|
||||||
import ControlPanel from './components/ControlPanel';
|
import ControlPanel from './components/ControlPanel';
|
||||||
import VoiceControl from './components/VoiceControl';
|
import VoiceControl from './components/VoiceControl';
|
||||||
@@ -12,17 +12,18 @@ import './App.css';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const connect = useTurtleStore((state) => state.connect);
|
const connect = useTurtleStore((state) => state.connect);
|
||||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
|
||||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
|
|
||||||
|
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
const renderPanelContent = () => {
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
|
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
|
||||||
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||||
|
|
||||||
switch (panelTab) {
|
switch (panelTab) {
|
||||||
@@ -47,36 +48,21 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="view-controls">
|
<div className="app-content split">
|
||||||
<button
|
<div className="map-container">
|
||||||
className={view === 'split' ? 'active' : ''}
|
<Map3D />
|
||||||
onClick={() => setView('split')}
|
</div>
|
||||||
>
|
<div className="panel-container">
|
||||||
📊 Split View
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={view === 'map' ? 'active' : ''}
|
|
||||||
onClick={() => setView('map')}
|
|
||||||
>
|
|
||||||
🗺️ Map Only
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={view === 'panel' ? 'active' : ''}
|
|
||||||
onClick={() => setView('panel')}
|
|
||||||
>
|
|
||||||
🎮 Control Only
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`app-content ${view}`}>
|
|
||||||
{(view === 'split' || view === 'map') && (
|
|
||||||
<div className="map-container">
|
|
||||||
<Map3D />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(view === 'split' || view === 'panel') && (
|
|
||||||
<div className="panel-container">
|
|
||||||
<div className="panel-tabs">
|
<div className="panel-tabs">
|
||||||
|
<a
|
||||||
|
href={inventoryDashboardUrl}
|
||||||
|
className="cross-link-btn"
|
||||||
|
title="Open Inventory Manager Dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
📦 Inventory
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
className={panelTab === 'control' ? 'active' : ''}
|
className={panelTab === 'control' ? 'active' : ''}
|
||||||
onClick={() => setPanelTab('control')}
|
onClick={() => setPanelTab('control')}
|
||||||
@@ -131,7 +117,6 @@ function App() {
|
|||||||
{renderPanelContent()}
|
{renderPanelContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,32 @@
|
|||||||
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 activeState = turtle.state || turtle.mode || 'idle';
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||||
const inventoryCount = turtle.inventory?.length || 0;
|
const inventoryCount = Array.isArray(turtle.inventory)
|
||||||
|
? turtle.inventory.length
|
||||||
|
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
const modeColors = {
|
const modeColors = {
|
||||||
mining: '#4ade80',
|
mining: '#55ff55',
|
||||||
exploring: '#60a5fa',
|
exploring: '#55ffff',
|
||||||
returning: '#f59e0b',
|
returning: '#ffaa00',
|
||||||
goHome: '#f59e0b',
|
goHome: '#ffaa00',
|
||||||
idle: '#9ca3af',
|
idle: '#aaaaaa',
|
||||||
manual: '#a78bfa',
|
manual: '#ff55ff',
|
||||||
refueling: '#ef4444',
|
refueling: '#ff5555',
|
||||||
farming: '#22c55e',
|
farming: '#55ff55',
|
||||||
dumpInventory: '#a855f7',
|
dumpInventory: '#aa00aa',
|
||||||
dumping: '#a855f7',
|
dumping: '#aa00aa',
|
||||||
moving: '#06b6d4',
|
moving: '#55ffff',
|
||||||
unknown: '#6b7280'
|
scan: '#5555ff',
|
||||||
|
extraction: '#ffaa00',
|
||||||
|
building: '#00aaaa',
|
||||||
|
autocraft: '#ff55ff',
|
||||||
|
unknown: '#555555'
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +36,7 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
|||||||
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
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[activeState] || modeColors.unknown }}>
|
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||||
{activeState}
|
{activeState}
|
||||||
</span>
|
</span>
|
||||||
@@ -72,8 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TurtleDetails({ turtle }) {
|
function TurtleDetails({ turtle }) {
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
|
||||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
|
||||||
|
const equipLeft = useTurtleStore((state) => state.equipLeft);
|
||||||
|
const equipRight = useTurtleStore((state) => state.equipRight);
|
||||||
|
const sortInventory = useTurtleStore((state) => state.sortInventory);
|
||||||
|
const selectSlot = useTurtleStore((state) => state.selectSlot);
|
||||||
|
const dropItems = useTurtleStore((state) => state.dropItems);
|
||||||
|
const suckItems = useTurtleStore((state) => state.suckItems);
|
||||||
|
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
|
||||||
|
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
|
||||||
|
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
|
||||||
|
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
|
||||||
|
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||||
|
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||||
|
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||||
|
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||||
|
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||||
|
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||||
|
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||||
|
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||||
|
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||||
|
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||||
|
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
|
||||||
|
|
||||||
if (!turtle) {
|
if (!turtle) {
|
||||||
return (
|
return (
|
||||||
@@ -83,19 +114,70 @@ function TurtleDetails({ turtle }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCommand = (command, param = null) => {
|
|
||||||
sendCommand(turtle.turtleID, command, param);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStateChange = (stateName, data = {}) => {
|
const handleStateChange = (stateName, data = {}) => {
|
||||||
setTurtleState(turtle.turtleID, stateName, data);
|
setTurtleState(turtle.turtleID, stateName, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (renameValue.trim()) {
|
||||||
|
await renameTurtle(turtle.turtleID, renameValue.trim());
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = async (slotIndex) => {
|
||||||
|
await selectSlot(turtle.turtleID, slotIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigSave = async () => {
|
||||||
|
await updateTurtleConfig(turtle.turtleID, configValues);
|
||||||
|
setShowConfig(false);
|
||||||
|
};
|
||||||
|
|
||||||
const activeState = turtle.state || turtle.mode || 'idle';
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turtle-details">
|
<div className="turtle-details">
|
||||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
|
||||||
|
|
||||||
|
{/* Rename + Config bar */}
|
||||||
|
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
|
placeholder="Rename turtle..."
|
||||||
|
className="rename-input"
|
||||||
|
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
|
||||||
|
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}>⚙️ Config</button>
|
||||||
|
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
|
||||||
|
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Modal */}
|
||||||
|
{showConfig && (
|
||||||
|
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
|
||||||
|
<h3>⚙️ Configuration</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Max Distance:</span>
|
||||||
|
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Auto Refuel:</span>
|
||||||
|
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
|
||||||
|
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Status</h3>
|
<h3>Status</h3>
|
||||||
@@ -116,6 +198,14 @@ function TurtleDetails({ turtle }) {
|
|||||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.totalSteps > 0 && (
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">Steps:</span>
|
||||||
|
<span className="value">
|
||||||
|
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Position:</span>
|
<span className="label">Position:</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
@@ -134,15 +224,44 @@ function TurtleDetails({ turtle }) {
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.error && (
|
||||||
|
<div className="status-item" style={{ color: '#ff5555' }}>
|
||||||
|
<span className="label">Error:</span>
|
||||||
|
<span className="value">{turtle.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{turtle.warning && (
|
||||||
|
<div className="status-item" style={{ color: '#ffaa00' }}>
|
||||||
|
<span className="label">Warning:</span>
|
||||||
|
<span className="value">{turtle.warning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Peripherals section */}
|
||||||
|
{turtle.peripherals && Object.keys(turtle.peripherals).length > 0 && (
|
||||||
|
<div className="detail-section">
|
||||||
|
<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">
|
<div className="detail-section">
|
||||||
<h3>State Machine</h3>
|
<h3>State Machine</h3>
|
||||||
<div className="command-grid">
|
<div className="command-grid">
|
||||||
<button
|
<button
|
||||||
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
||||||
onClick={() => { handleStateChange('idle'); handleCommand('stop'); }}
|
onClick={() => handleStateChange('idle')}
|
||||||
title="Stop and go idle"
|
title="Stop and go idle"
|
||||||
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
@@ -150,7 +269,7 @@ function TurtleDetails({ turtle }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
||||||
onClick={() => { handleStateChange('exploring'); handleCommand('explore'); }}
|
onClick={() => handleStateChange('exploring')}
|
||||||
title="Autonomous exploration"
|
title="Autonomous exploration"
|
||||||
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
@@ -158,7 +277,7 @@ function TurtleDetails({ turtle }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
||||||
onClick={() => { handleStateChange('mining'); handleCommand('explore'); }}
|
onClick={() => handleStateChange('mining')}
|
||||||
title="Mining with ore priority"
|
title="Mining with ore priority"
|
||||||
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
@@ -174,7 +293,7 @@ function TurtleDetails({ turtle }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
||||||
onClick={() => { handleStateChange('goHome'); handleCommand('returnHome'); }}
|
onClick={() => handleStateChange('goHome')}
|
||||||
title="Navigate home"
|
title="Navigate home"
|
||||||
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
@@ -182,7 +301,7 @@ function TurtleDetails({ turtle }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
||||||
onClick={() => { handleStateChange('refueling'); handleCommand('refuel'); }}
|
onClick={() => handleStateChange('refueling')}
|
||||||
title="Auto-refuel from inventory"
|
title="Auto-refuel from inventory"
|
||||||
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
@@ -204,67 +323,69 @@ function TurtleDetails({ turtle }) {
|
|||||||
>
|
>
|
||||||
🧭 Move To
|
🧭 Move To
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</div>
|
className={`command-btn ${activeState === 'scan' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleStateChange('scan')}
|
||||||
<div className="detail-section">
|
title="Scan surroundings with peripheral scanner"
|
||||||
<h3>Legacy Commands</h3>
|
style={activeState === 'scan' ? { outline: '2px solid #fff' } : {}}
|
||||||
<div className="command-grid">
|
|
||||||
<button
|
|
||||||
className="command-btn explore"
|
|
||||||
onClick={() => handleCommand('explore')}
|
|
||||||
title="Start autonomous exploration and mining"
|
|
||||||
>
|
>
|
||||||
🔍 Explore
|
📡 Scan
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn mine"
|
className={`command-btn ${activeState === 'extraction' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('mine')}
|
onClick={() => {
|
||||||
title="Start mining mode"
|
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' } : {}}
|
||||||
>
|
>
|
||||||
⛏️ Mine
|
💎 Extract
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn return"
|
className={`command-btn ${activeState === 'building' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('returnHome')}
|
onClick={() => {
|
||||||
title="Navigate back to home position"
|
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' } : {}}
|
||||||
>
|
>
|
||||||
🏠 Return Home
|
🏗️ Build
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn stop"
|
className={`command-btn ${activeState === 'autocraft' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('stop')}
|
onClick={() => {
|
||||||
title="Stop all autonomous actions"
|
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' } : {}}
|
||||||
>
|
>
|
||||||
⏹️ Stop
|
🔨 Autocraft
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn manual"
|
|
||||||
onClick={() => handleCommand('manual')}
|
|
||||||
title="Switch to manual control mode"
|
|
||||||
>
|
|
||||||
🎮 Manual Mode
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn setHome"
|
|
||||||
onClick={() => handleCommand('setHome')}
|
|
||||||
title="Set current position as home"
|
|
||||||
>
|
|
||||||
📍 Set Home
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn refuel"
|
|
||||||
onClick={() => handleCommand('refuel')}
|
|
||||||
title="Consume fuel from inventory"
|
|
||||||
>
|
|
||||||
⛽ Refuel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn status"
|
|
||||||
onClick={() => handleCommand('status')}
|
|
||||||
title="Request status update"
|
|
||||||
>
|
|
||||||
📊 Status
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,16 +394,16 @@ function TurtleDetails({ turtle }) {
|
|||||||
<h3>Movement</h3>
|
<h3>Movement</h3>
|
||||||
<div className="movement-controls">
|
<div className="movement-controls">
|
||||||
<div className="movement-row">
|
<div className="movement-row">
|
||||||
<button onClick={() => handleCommand('forward')} title="Move forward">↑</button>
|
<button onClick={() => moveForward(turtle.turtleID)} title="Move forward">↑</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="movement-row">
|
<div className="movement-row">
|
||||||
<button onClick={() => handleCommand('turnLeft')} title="Turn left">←</button>
|
<button onClick={() => turnLeft(turtle.turtleID)} title="Turn left">←</button>
|
||||||
<button onClick={() => handleCommand('back')} title="Move backward">↓</button>
|
<button onClick={() => moveBack(turtle.turtleID)} title="Move backward">↓</button>
|
||||||
<button onClick={() => handleCommand('turnRight')} title="Turn right">→</button>
|
<button onClick={() => turnRight(turtle.turtleID)} title="Turn right">→</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="movement-row vertical">
|
<div className="movement-row vertical">
|
||||||
<button onClick={() => handleCommand('up')} title="Move up">⬆ Up</button>
|
<button onClick={() => moveUp(turtle.turtleID)} title="Move up">⬆ Up</button>
|
||||||
<button onClick={() => handleCommand('down')} title="Move down">⬇ Down</button>
|
<button onClick={() => moveDown(turtle.turtleID)} title="Move down">⬇ Down</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,28 +413,28 @@ function TurtleDetails({ turtle }) {
|
|||||||
<div className="action-grid">
|
<div className="action-grid">
|
||||||
<button
|
<button
|
||||||
className="action-btn dig"
|
className="action-btn dig"
|
||||||
onClick={() => handleCommand('dig')}
|
onClick={() => digBlock(turtle.turtleID)}
|
||||||
title="Dig block in front"
|
title="Dig block in front"
|
||||||
>
|
>
|
||||||
⛏️ Dig
|
⛏️ Dig
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn digup"
|
className="action-btn digup"
|
||||||
onClick={() => handleCommand('digUp')}
|
onClick={() => digBlockUp(turtle.turtleID)}
|
||||||
title="Dig block above"
|
title="Dig block above"
|
||||||
>
|
>
|
||||||
⬆️ Dig Up
|
⬆️ Dig Up
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn digdown"
|
className="action-btn digdown"
|
||||||
onClick={() => handleCommand('digDown')}
|
onClick={() => digBlockDown(turtle.turtleID)}
|
||||||
title="Dig block below"
|
title="Dig block below"
|
||||||
>
|
>
|
||||||
⬇️ Dig Down
|
⬇️ Dig Down
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn place"
|
className="action-btn place"
|
||||||
onClick={() => handleCommand('place')}
|
onClick={() => placeBlock(turtle.turtleID)}
|
||||||
title="Place block from inventory"
|
title="Place block from inventory"
|
||||||
>
|
>
|
||||||
🧱 Place
|
🧱 Place
|
||||||
@@ -321,17 +442,86 @@ function TurtleDetails({ turtle }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Equipment & Inventory Actions */}
|
||||||
|
<div className="detail-section">
|
||||||
|
<h3>Equipment & Inventory</h3>
|
||||||
|
<div className="action-grid">
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => equipLeft(turtle.turtleID)}
|
||||||
|
title="Equip selected item on left side"
|
||||||
|
>
|
||||||
|
🛡️ Equip L
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => equipRight(turtle.turtleID)}
|
||||||
|
title="Equip selected item on right side"
|
||||||
|
>
|
||||||
|
🗡️ Equip R
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => sortInventory(turtle.turtleID)}
|
||||||
|
title="Sort and compact inventory"
|
||||||
|
>
|
||||||
|
📋 Sort
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'front')}
|
||||||
|
title="Drop items forward"
|
||||||
|
>
|
||||||
|
📤 Drop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'up')}
|
||||||
|
title="Drop items upward"
|
||||||
|
>
|
||||||
|
⬆️ Drop Up
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'down')}
|
||||||
|
title="Drop items downward"
|
||||||
|
>
|
||||||
|
⬇️ Drop Down
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => suckItems(turtle.turtleID, 'front')}
|
||||||
|
title="Suck items from front"
|
||||||
|
>
|
||||||
|
📥 Suck
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const side = prompt('Enter peripheral side (front/top/bottom/left/right):');
|
||||||
|
if (side) connectToInventory(turtle.turtleID, side);
|
||||||
|
}}
|
||||||
|
title="Read adjacent inventory peripheral contents"
|
||||||
|
>
|
||||||
|
📦 Read Inv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{turtle.inventory && turtle.inventory.length > 0 && (
|
{turtle.inventory && turtle.inventory.length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16)</h3>
|
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) — Slot: {turtle.selectedSlot || 1}</h3>
|
||||||
<div className="inventory-grid">
|
<div className="inventory-grid">
|
||||||
{Array.from({ length: 16 }, (_, slotIndex) => {
|
{Array.from({ length: 16 }, (_, slotIndex) => {
|
||||||
const item = turtle.inventory[slotIndex];
|
const item = turtle.inventory[slotIndex];
|
||||||
|
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slotIndex}
|
key={slotIndex}
|
||||||
className={`inventory-slot ${item ? 'filled' : 'empty'}`}
|
className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
|
||||||
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count})` : 'Empty'}
|
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count}) — Click to select` : `Slot ${slotIndex + 1} — Click to select`}
|
||||||
|
onClick={() => handleSlotClick(slotIndex)}
|
||||||
|
style={isSelected ? { outline: '2px solid #55ffff', outlineOffset: '-2px' } : {}}
|
||||||
>
|
>
|
||||||
{item ? (
|
{item ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Groups Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.groups-panel {
|
.groups-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-header {
|
.groups-header {
|
||||||
@@ -16,19 +20,20 @@
|
|||||||
.groups-header h2 {
|
.groups-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-count {
|
.group-count {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -36,15 +41,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -60,17 +65,19 @@
|
|||||||
|
|
||||||
/* Create Group Section */
|
/* Create Group Section */
|
||||||
.create-group-section {
|
.create-group-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-section h3 {
|
.create-group-section h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form {
|
.create-group-form {
|
||||||
@@ -81,16 +88,16 @@
|
|||||||
|
|
||||||
.create-group-form input {
|
.create-group-form input {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form input:focus {
|
.create-group-form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form input:disabled {
|
.create-group-form input:disabled {
|
||||||
@@ -107,11 +114,11 @@
|
|||||||
.color-option {
|
.color-option {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 0.375rem;
|
border: 3px solid #1a1a1a;
|
||||||
border: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.3), inset 0 2px 0 rgba(255,255,255,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-option:hover {
|
.color-option:hover {
|
||||||
@@ -119,25 +126,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-option.active {
|
.color-option.active {
|
||||||
border-color: #e5e7eb;
|
border-color: #ffff55;
|
||||||
box-shadow: 0 0 0 2px #0f172a, 0 0 0 4px currentColor;
|
box-shadow: 0 0 0 2px #1a1a1a, 0 0 0 4px #ffff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"] {
|
.create-group-form button[type="submit"] {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"]:disabled {
|
.create-group-form button[type="submit"]:disabled {
|
||||||
@@ -153,15 +161,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-card {
|
.group-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-card:hover {
|
.group-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
@@ -182,24 +190,23 @@
|
|||||||
.group-color-indicator {
|
.group-color-indicator {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
border-radius: 50%;
|
border: 2px solid #1a1a1a;
|
||||||
border: 2px solid #0f172a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-title h3 {
|
.group-title h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #e0e0e0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-count {
|
.member-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-group-btn {
|
.delete-group-btn {
|
||||||
@@ -209,7 +216,7 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-group-btn:hover {
|
.delete-group-btn:hover {
|
||||||
@@ -225,7 +232,7 @@
|
|||||||
.group-members h4 {
|
.group-members h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -243,13 +250,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item:hover {
|
.member-item:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-info {
|
.member-info {
|
||||||
@@ -266,7 +274,7 @@
|
|||||||
.member-name {
|
.member-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-status {
|
.member-status {
|
||||||
@@ -278,11 +286,11 @@
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-member-btn:hover {
|
.remove-member-btn:hover {
|
||||||
@@ -293,7 +301,7 @@
|
|||||||
.no-members {
|
.no-members {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -305,34 +313,34 @@
|
|||||||
.add-member-section select {
|
.add-member-section select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-member-section select:hover {
|
.add-member-section select:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-member-section select:focus {
|
.add-member-section select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Group Commands */
|
/* Group Commands */
|
||||||
.group-commands {
|
.group-commands {
|
||||||
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||||
border-top: 1px solid #334155;
|
border-top: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-commands h4 {
|
.group-commands h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -346,21 +354,21 @@
|
|||||||
|
|
||||||
.command-buttons button {
|
.command-buttons button {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: #0f172a;
|
background: #6b6b6b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-buttons button:hover {
|
.command-buttons button:hover {
|
||||||
background: #1e293b;
|
background: #7b7b7b;
|
||||||
border-color: #3b82f6;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -378,13 +386,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
|||||||
const [message, setMessage] = useState(null);
|
const [message, setMessage] = useState(null);
|
||||||
|
|
||||||
const colorPresets = [
|
const colorPresets = [
|
||||||
{ name: 'Blue', value: '#3b82f6' },
|
{ name: 'Blue', value: '#345ec3' },
|
||||||
{ name: 'Green', value: '#10b981' },
|
{ name: 'Green', value: '#4a8c2a' },
|
||||||
{ name: 'Red', value: '#ef4444' },
|
{ name: 'Red', value: '#aa0000' },
|
||||||
{ name: 'Yellow', value: '#f59e0b' },
|
{ name: 'Yellow', value: '#ffaa00' },
|
||||||
{ name: 'Purple', value: '#8b5cf6' },
|
{ name: 'Purple', value: '#7b2fbe' },
|
||||||
{ name: 'Pink', value: '#ec4899' },
|
{ name: 'Pink', value: '#d4658a' },
|
||||||
{ name: 'Cyan', value: '#06b6d4' },
|
{ name: 'Cyan', value: '#55ffff' },
|
||||||
{ name: 'Orange', value: '#f97316' },
|
{ name: 'Orange', value: '#c97a2a' },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import React, { useRef, useMemo, useEffect, useState } from 'react';
|
import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||||
import { Canvas, useFrame, useLoader } from '@react-three/fiber';
|
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
|
||||||
import { OrbitControls, Grid, Text, Line } from '@react-three/drei';
|
import { OrbitControls, Grid, Text, Line } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useTurtleStore } from '../store/turtleStore';
|
import { useTurtleStore } from '../store/turtleStore';
|
||||||
|
|
||||||
|
// World interaction modes
|
||||||
|
const INTERACTION_MODE = {
|
||||||
|
LOOK: 'look', // Default orbit camera
|
||||||
|
MOVE: 'move', // Click block to send turtle there
|
||||||
|
BUILD: 'build', // Click face to place block preview
|
||||||
|
SELECT: 'select' // Click-drag area selection
|
||||||
|
};
|
||||||
|
|
||||||
// Texture mapping for Minecraft blocks
|
// Texture mapping for Minecraft blocks
|
||||||
const TEXTURE_MAP = {
|
const TEXTURE_MAP = {
|
||||||
// Basic blocks
|
// Basic blocks
|
||||||
@@ -533,23 +541,35 @@ function getBlockAppearance(blockName) {
|
|||||||
return { color: '#666666', pattern: 'solid' };
|
return { color: '#666666', pattern: 'solid' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorldBlocks component to render discovered blocks with Minecraft textures
|
// WorldBlocks component using chunked InstancedMesh rendering for performance
|
||||||
function WorldBlocks({ blocks }) {
|
function WorldBlocks({ blocks, interactionMode, selectedTurtleId, onBlockClick }) {
|
||||||
const [hoveredBlock, setHoveredBlock] = useState(null);
|
const [hoveredBlock, setHoveredBlock] = useState(null);
|
||||||
const { textures, multiFaceTextures, loading } = useMinecraftTextures(blocks);
|
const { textures, multiFaceTextures, loading } = useMinecraftTextures(blocks);
|
||||||
|
|
||||||
// Group blocks by block name for better performance
|
// Chunk size (16x16 like Minecraft)
|
||||||
const blockGroups = useMemo(() => {
|
const CHUNK_SIZE = 16;
|
||||||
const groups = new Map();
|
|
||||||
|
// Group blocks into chunks and by block type within each chunk
|
||||||
|
const chunks = useMemo(() => {
|
||||||
|
const chunkMap = new Map();
|
||||||
|
|
||||||
blocks.forEach(block => {
|
blocks.forEach(block => {
|
||||||
if (!groups.has(block.name)) {
|
const chunkX = Math.floor(block.x / CHUNK_SIZE);
|
||||||
groups.set(block.name, []);
|
const chunkZ = Math.floor(block.z / CHUNK_SIZE);
|
||||||
|
const chunkKey = `${chunkX},${chunkZ}`;
|
||||||
|
|
||||||
|
if (!chunkMap.has(chunkKey)) {
|
||||||
|
chunkMap.set(chunkKey, { key: chunkKey, chunkX, chunkZ, blocksByType: new Map() });
|
||||||
}
|
}
|
||||||
groups.get(block.name).push(block);
|
|
||||||
|
const chunk = chunkMap.get(chunkKey);
|
||||||
|
if (!chunk.blocksByType.has(block.name)) {
|
||||||
|
chunk.blocksByType.set(block.name, []);
|
||||||
|
}
|
||||||
|
chunk.blocksByType.get(block.name).push(block);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(groups.entries());
|
return Array.from(chunkMap.values());
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -558,84 +578,224 @@ function WorldBlocks({ blocks }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{blockGroups.map(([blockName, blockList]) => {
|
{chunks.map(chunk => (
|
||||||
const appearance = getBlockAppearance(blockName);
|
<ChunkMesh
|
||||||
const texture = textures[blockName];
|
key={chunk.key}
|
||||||
const multiFace = multiFaceTextures[blockName];
|
chunk={chunk}
|
||||||
|
textures={textures}
|
||||||
return (
|
multiFaceTextures={multiFaceTextures}
|
||||||
<group key={blockName}>
|
hoveredBlock={hoveredBlock}
|
||||||
{blockList.map((block) => {
|
setHoveredBlock={setHoveredBlock}
|
||||||
const blockKey = `${block.x},${block.y},${block.z}`;
|
interactionMode={interactionMode}
|
||||||
const isHovered = hoveredBlock === blockKey;
|
selectedTurtleId={selectedTurtleId}
|
||||||
|
onBlockClick={onBlockClick}
|
||||||
return (
|
/>
|
||||||
<group key={blockKey}>
|
))}
|
||||||
<mesh
|
|
||||||
position={[block.x, block.y, block.z]}
|
{/* Hover label overlay */}
|
||||||
onPointerOver={(e) => {
|
{hoveredBlock && (
|
||||||
e.stopPropagation();
|
<Text
|
||||||
setHoveredBlock(blockKey);
|
position={[hoveredBlock.x, hoveredBlock.y + 1.0, hoveredBlock.z]}
|
||||||
}}
|
fontSize={0.3}
|
||||||
onPointerOut={() => setHoveredBlock(null)}
|
color="white"
|
||||||
>
|
anchorX="center"
|
||||||
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
anchorY="middle"
|
||||||
{multiFace ? (
|
outlineWidth={0.05}
|
||||||
// Multi-face block (different top/side/bottom)
|
outlineColor="#000000"
|
||||||
// Box face order: +x, -x, +y, -y, +z, -z
|
>
|
||||||
multiFace.map((faceTexture, i) => (
|
{hoveredBlock.name.replace('minecraft:', '').replace(/_/g, ' ').toUpperCase()}
|
||||||
<meshStandardMaterial
|
{'\n'}
|
||||||
key={i}
|
{`(${hoveredBlock.x}, ${hoveredBlock.y}, ${hoveredBlock.z})`}
|
||||||
attach={`material-${i}`}
|
</Text>
|
||||||
map={faceTexture}
|
)}
|
||||||
color="#ffffff"
|
|
||||||
emissive={appearance.emissive || '#000000'}
|
|
||||||
emissiveIntensity={isHovered ? 0.25 : (appearance.intensity || 0.02)}
|
|
||||||
roughness={0.8}
|
|
||||||
metalness={0.0}
|
|
||||||
transparent
|
|
||||||
opacity={isHovered ? 0.98 : 0.9}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
// Single texture or color fallback
|
|
||||||
<meshStandardMaterial
|
|
||||||
map={texture}
|
|
||||||
color={texture ? '#ffffff' : appearance.color}
|
|
||||||
emissive={appearance.emissive || appearance.color}
|
|
||||||
emissiveIntensity={isHovered ? (appearance.intensity || 0.05) + 0.2 : (appearance.intensity || 0.05)}
|
|
||||||
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8}
|
|
||||||
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0}
|
|
||||||
transparent
|
|
||||||
opacity={isHovered ? 0.98 : 0.9}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Block label on hover */}
|
|
||||||
{isHovered && (
|
|
||||||
<Text
|
|
||||||
position={[block.x, block.y + 0.8, block.z]}
|
|
||||||
fontSize={0.3}
|
|
||||||
color="white"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
outlineWidth={0.05}
|
|
||||||
outlineColor="#000000"
|
|
||||||
>
|
|
||||||
{block.name.replace('minecraft:', '').replace(/_/g, ' ').toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Individual chunk rendered with InstancedMesh per block type
|
||||||
|
function ChunkMesh({ chunk, textures, multiFaceTextures, hoveredBlock, setHoveredBlock, interactionMode, selectedTurtleId, onBlockClick }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{Array.from(chunk.blocksByType.entries()).map(([blockName, blockList]) => (
|
||||||
|
<BlockTypeInstances
|
||||||
|
key={`${chunk.key}-${blockName}`}
|
||||||
|
blockName={blockName}
|
||||||
|
blockList={blockList}
|
||||||
|
textures={textures}
|
||||||
|
multiFaceTextures={multiFaceTextures}
|
||||||
|
hoveredBlock={hoveredBlock}
|
||||||
|
setHoveredBlock={setHoveredBlock}
|
||||||
|
interactionMode={interactionMode}
|
||||||
|
selectedTurtleId={selectedTurtleId}
|
||||||
|
onBlockClick={onBlockClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstancedMesh for all blocks of one type within a chunk
|
||||||
|
function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures, hoveredBlock, setHoveredBlock, interactionMode, selectedTurtleId, onBlockClick }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
const appearance = useMemo(() => getBlockAppearance(blockName), [blockName]);
|
||||||
|
const texture = textures[blockName];
|
||||||
|
const multiFace = multiFaceTextures[blockName];
|
||||||
|
const tempMatrix = useMemo(() => new THREE.Matrix4(), []);
|
||||||
|
const tempColor = useMemo(() => new THREE.Color(), []);
|
||||||
|
|
||||||
|
// Set instance matrices
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meshRef.current) return;
|
||||||
|
|
||||||
|
blockList.forEach((block, i) => {
|
||||||
|
tempMatrix.setPosition(block.x, block.y, block.z);
|
||||||
|
meshRef.current.setMatrixAt(i, tempMatrix);
|
||||||
|
// Default color (white for textured, appearance color for untextured)
|
||||||
|
tempColor.set(texture || multiFace ? '#ffffff' : appearance.color);
|
||||||
|
meshRef.current.setColorAt(i, tempColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (meshRef.current.instanceColor) meshRef.current.instanceColor.needsUpdate = true;
|
||||||
|
}, [blockList, texture, multiFace, appearance, tempMatrix, tempColor]);
|
||||||
|
|
||||||
|
// Update hover highlighting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meshRef.current || !meshRef.current.instanceColor) return;
|
||||||
|
|
||||||
|
blockList.forEach((block, i) => {
|
||||||
|
const isHovered = hoveredBlock &&
|
||||||
|
hoveredBlock.x === block.x &&
|
||||||
|
hoveredBlock.y === block.y &&
|
||||||
|
hoveredBlock.z === block.z;
|
||||||
|
|
||||||
|
if (isHovered) {
|
||||||
|
tempColor.set('#ffffff');
|
||||||
|
meshRef.current.setColorAt(i, tempColor);
|
||||||
|
} else {
|
||||||
|
tempColor.set(texture || multiFace ? '#ffffff' : appearance.color);
|
||||||
|
meshRef.current.setColorAt(i, tempColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
meshRef.current.instanceColor.needsUpdate = true;
|
||||||
|
}, [hoveredBlock, blockList, texture, multiFace, appearance, tempColor]);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const instanceId = e.instanceId;
|
||||||
|
if (instanceId !== undefined && instanceId < blockList.length) {
|
||||||
|
setHoveredBlock(blockList[instanceId]);
|
||||||
|
}
|
||||||
|
}, [blockList, setHoveredBlock]);
|
||||||
|
|
||||||
|
const handlePointerOut = useCallback(() => {
|
||||||
|
setHoveredBlock(null);
|
||||||
|
}, [setHoveredBlock]);
|
||||||
|
|
||||||
|
const handleClick = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const instanceId = e.instanceId;
|
||||||
|
if (instanceId !== undefined && instanceId < blockList.length) {
|
||||||
|
const block = blockList[instanceId];
|
||||||
|
|
||||||
|
if (interactionMode === INTERACTION_MODE.MOVE && selectedTurtleId && onBlockClick) {
|
||||||
|
// In MOVE mode, click block surface to send turtle to adjacent position
|
||||||
|
const normal = e.face?.normal;
|
||||||
|
const target = {
|
||||||
|
x: block.x + (normal?.x || 0),
|
||||||
|
y: block.y + (normal?.y || 0),
|
||||||
|
z: block.z + (normal?.z || 0)
|
||||||
|
};
|
||||||
|
onBlockClick({ type: 'move', target, block });
|
||||||
|
} else if (interactionMode === INTERACTION_MODE.BUILD && onBlockClick) {
|
||||||
|
const normal = e.face?.normal;
|
||||||
|
const target = {
|
||||||
|
x: block.x + (normal?.x || 0),
|
||||||
|
y: block.y + (normal?.y || 0),
|
||||||
|
z: block.z + (normal?.z || 0)
|
||||||
|
};
|
||||||
|
onBlockClick({ type: 'build', target, block });
|
||||||
|
} else if (interactionMode === INTERACTION_MODE.SELECT && onBlockClick) {
|
||||||
|
onBlockClick({ type: 'select', block });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [blockList, interactionMode, selectedTurtleId, onBlockClick]);
|
||||||
|
|
||||||
|
// For multi-face blocks, we can't easily use InstancedMesh with different material per face,
|
||||||
|
// so we fall back to individual meshes for those (they're fewer — grass, logs, etc.)
|
||||||
|
if (multiFace) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{blockList.map((block) => {
|
||||||
|
const blockKey = `${block.x},${block.y},${block.z}`;
|
||||||
|
const isHovered = hoveredBlock &&
|
||||||
|
hoveredBlock.x === block.x &&
|
||||||
|
hoveredBlock.y === block.y &&
|
||||||
|
hoveredBlock.z === block.z;
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={blockKey}
|
||||||
|
position={[block.x, block.y, block.z]}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
onPointerOver={(e) => { e.stopPropagation(); setHoveredBlock(block); }}
|
||||||
|
onPointerOut={() => setHoveredBlock(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (interactionMode === INTERACTION_MODE.MOVE && selectedTurtleId && onBlockClick) {
|
||||||
|
const normal = e.face?.normal;
|
||||||
|
onBlockClick({ type: 'move', target: { x: block.x + (normal?.x || 0), y: block.y + (normal?.y || 0), z: block.z + (normal?.z || 0) }, block });
|
||||||
|
} else if (interactionMode === INTERACTION_MODE.BUILD && onBlockClick) {
|
||||||
|
const normal = e.face?.normal;
|
||||||
|
onBlockClick({ type: 'build', target: { x: block.x + (normal?.x || 0), y: block.y + (normal?.y || 0), z: block.z + (normal?.z || 0) }, block });
|
||||||
|
} else if (interactionMode === INTERACTION_MODE.SELECT && onBlockClick) {
|
||||||
|
onBlockClick({ type: 'select', block });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
||||||
|
{multiFace.map((faceTexture, i) => (
|
||||||
|
<meshStandardMaterial
|
||||||
|
key={i}
|
||||||
|
attach={`material-${i}`}
|
||||||
|
map={faceTexture}
|
||||||
|
color="#ffffff"
|
||||||
|
emissive={appearance.emissive || '#000000'}
|
||||||
|
emissiveIntensity={isHovered ? 0.3 : (appearance.intensity || 0)}
|
||||||
|
roughness={0.85}
|
||||||
|
metalness={0.0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh
|
||||||
|
ref={meshRef}
|
||||||
|
args={[null, null, blockList.length]}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerOut={handlePointerOut}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={texture}
|
||||||
|
color={texture ? '#ffffff' : appearance.color}
|
||||||
|
emissive={appearance.emissive || '#000000'}
|
||||||
|
emissiveIntensity={appearance.intensity || 0}
|
||||||
|
roughness={appearance.pattern === 'ore' ? 0.5 : 0.9}
|
||||||
|
metalness={appearance.pattern === 'ore' ? 0.15 : 0.0}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Path trail component (old version removed above)
|
// Path trail component (old version removed above)
|
||||||
function OldPathTrail({ turtle }) {
|
function OldPathTrail({ turtle }) {
|
||||||
const { position, homePosition } = turtle;
|
const { position, homePosition } = turtle;
|
||||||
@@ -698,8 +858,9 @@ function MiningArea({ area, turtle, isSelected, onClick }) {
|
|||||||
const centerY = (area.startY + area.endY) / 2;
|
const centerY = (area.startY + area.endY) / 2;
|
||||||
const centerZ = (area.startZ + area.endZ) / 2;
|
const centerZ = (area.startZ + area.endZ) / 2;
|
||||||
|
|
||||||
// Color based on turtle or status
|
// Color based on area's custom color, status fallback
|
||||||
const getColor = () => {
|
const getColor = () => {
|
||||||
|
if (area.color) return area.color;
|
||||||
if (area.status === 'completed') return '#10b981'; // green
|
if (area.status === 'completed') return '#10b981'; // green
|
||||||
if (area.status === 'mining') return '#f59e0b'; // orange
|
if (area.status === 'mining') return '#f59e0b'; // orange
|
||||||
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
|
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
|
||||||
@@ -817,29 +978,189 @@ function PlayerMarker({ player }) {
|
|||||||
outlineWidth={0.05}
|
outlineWidth={0.05}
|
||||||
outlineColor="#000000"
|
outlineColor="#000000"
|
||||||
>
|
>
|
||||||
Player {player.playerID}
|
{player.label || `Player ${player.playerID}`}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move target preview (ghost block showing where turtle will go)
|
||||||
|
function MoveTargetPreview({ position }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.position.y = position.y + Math.sin(state.clock.elapsedTime * 3) * 0.1;
|
||||||
|
meshRef.current.material.opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh ref={meshRef} position={[position.x, position.y, position.z]}>
|
||||||
|
<boxGeometry args={[0.8, 0.8, 0.8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#3b82f6"
|
||||||
|
transparent
|
||||||
|
opacity={0.4}
|
||||||
|
emissive="#3b82f6"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<Text
|
||||||
|
position={[position.x, position.y + 1.2, position.z]}
|
||||||
|
fontSize={0.3}
|
||||||
|
color="#3b82f6"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.04}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
MOVE HERE
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build preview (ghost block showing where block will be placed)
|
||||||
|
function BuildPreview({ position }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.material.opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={meshRef} position={[position.x, position.y, position.z]}>
|
||||||
|
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#22c55e"
|
||||||
|
transparent
|
||||||
|
opacity={0.4}
|
||||||
|
emissive="#22c55e"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
wireframe={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area selection wireframe for SELECT mode
|
||||||
|
function SelectionArea({ start, end }) {
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
const minX = Math.min(start.x, end.x);
|
||||||
|
const minY = Math.min(start.y, end.y);
|
||||||
|
const minZ = Math.min(start.z, end.z);
|
||||||
|
const maxX = Math.max(start.x, end.x);
|
||||||
|
const maxY = Math.max(start.y, end.y);
|
||||||
|
const maxZ = Math.max(start.z, end.z);
|
||||||
|
|
||||||
|
const width = maxX - minX + 1;
|
||||||
|
const height = maxY - minY + 1;
|
||||||
|
const depth = maxZ - minZ + 1;
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[centerX, centerY, centerZ]}>
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(width, height, depth)]} />
|
||||||
|
<lineBasicMaterial color="#f59e0b" linewidth={2} />
|
||||||
|
</lineSegments>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[width, height, depth]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f59e0b"
|
||||||
|
transparent
|
||||||
|
opacity={0.1}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<Text
|
||||||
|
position={[0, height / 2 + 0.8, 0]}
|
||||||
|
fontSize={0.4}
|
||||||
|
color="#f59e0b"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.04}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{`${width}×${height}×${depth} Selection`}
|
||||||
</Text>
|
</Text>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main scene component
|
// Main scene component
|
||||||
function Scene() {
|
function Scene({ interactionMode, onInteraction }) {
|
||||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
const players = useTurtleStore((state) => Object.values(state.players || {}));
|
const players = useTurtleStore((state) => Object.values(state.players || {}));
|
||||||
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
|
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
|
||||||
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
|
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
|
||||||
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
||||||
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
|
||||||
// Mining areas state
|
// Mining areas state
|
||||||
const [miningAreas, setMiningAreas] = useState([]);
|
const [miningAreas, setMiningAreas] = useState([]);
|
||||||
const [selectedAreaId, setSelectedAreaId] = useState(null);
|
const [selectedAreaId, setSelectedAreaId] = useState(null);
|
||||||
|
|
||||||
|
// Interaction mode state
|
||||||
|
const [moveTarget, setMoveTarget] = useState(null);
|
||||||
|
const [buildPreview, setBuildPreview] = useState(null);
|
||||||
|
const [selectionStart, setSelectionStart] = useState(null);
|
||||||
|
const [selectionEnd, setSelectionEnd] = useState(null);
|
||||||
|
const controlsRef = useRef();
|
||||||
|
|
||||||
|
// Handle block clicks from interaction modes
|
||||||
|
const handleBlockClick = useCallback((event) => {
|
||||||
|
if (event.type === 'move' && selectedTurtleId) {
|
||||||
|
setMoveTarget(event.target);
|
||||||
|
// Send the turtle to the clicked position
|
||||||
|
setTurtleState(selectedTurtleId, 'moving', {
|
||||||
|
target: event.target
|
||||||
|
});
|
||||||
|
// Clear preview after a moment
|
||||||
|
setTimeout(() => setMoveTarget(null), 2000);
|
||||||
|
if (onInteraction) onInteraction({ mode: 'move', target: event.target, turtleId: selectedTurtleId });
|
||||||
|
} else if (event.type === 'build') {
|
||||||
|
setBuildPreview(event.target);
|
||||||
|
if (onInteraction) onInteraction({ mode: 'build', target: event.target });
|
||||||
|
} else if (event.type === 'select') {
|
||||||
|
if (!selectionStart) {
|
||||||
|
setSelectionStart(event.block);
|
||||||
|
setSelectionEnd(null);
|
||||||
|
} else {
|
||||||
|
setSelectionEnd(event.block);
|
||||||
|
if (onInteraction) onInteraction({
|
||||||
|
mode: 'select',
|
||||||
|
start: selectionStart,
|
||||||
|
end: event.block
|
||||||
|
});
|
||||||
|
// Reset after selection is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionEnd(null);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedTurtleId, selectionStart, setTurtleState, onInteraction]);
|
||||||
|
|
||||||
|
// Disable orbit controls when in interactive mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.enabled = interactionMode === INTERACTION_MODE.LOOK;
|
||||||
|
}
|
||||||
|
}, [interactionMode]);
|
||||||
|
|
||||||
// Load mining areas
|
// Load mining areas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMiningAreas = async () => {
|
const fetchMiningAreas = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3001/api/mining-areas');
|
const response = await fetch(`${window.location.origin}/api/mining-areas`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setMiningAreas(data);
|
setMiningAreas(data);
|
||||||
@@ -879,28 +1200,72 @@ function Scene() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ambientLight intensity={0.6} />
|
{/* Soft ambient base - prevents pure black shadows */}
|
||||||
<pointLight position={[10, 10, 10]} intensity={1.2} />
|
<ambientLight intensity={0.3} color="#b0c4ff" />
|
||||||
<pointLight position={[-10, -10, -10]} intensity={0.6} />
|
|
||||||
<pointLight position={[0, 20, 0]} intensity={0.8} color="#ffffff" />
|
{/* Hemisphere light - sky blue from above, ground brown from below */}
|
||||||
|
<hemisphereLight
|
||||||
|
args={['#87ceeb', '#3b2f1e', 0.4]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main directional sunlight with shadows */}
|
||||||
|
<directionalLight
|
||||||
|
position={[30, 50, 20]}
|
||||||
|
intensity={1.8}
|
||||||
|
color="#fff5e6"
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize-width={2048}
|
||||||
|
shadow-mapSize-height={2048}
|
||||||
|
shadow-camera-far={150}
|
||||||
|
shadow-camera-left={-60}
|
||||||
|
shadow-camera-right={60}
|
||||||
|
shadow-camera-top={60}
|
||||||
|
shadow-camera-bottom={-60}
|
||||||
|
shadow-bias={-0.0005}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtle fill light from opposite side to soften shadows */}
|
||||||
|
<directionalLight
|
||||||
|
position={[-20, 10, -15]}
|
||||||
|
intensity={0.3}
|
||||||
|
color="#8ec5fc"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shadow-receiving ground plane */}
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
|
||||||
|
<planeGeometry args={[200, 200]} />
|
||||||
|
<shadowMaterial transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<Grid
|
<Grid
|
||||||
args={[100, 100]}
|
args={[100, 100]}
|
||||||
cellSize={1}
|
cellSize={1}
|
||||||
cellThickness={0.5}
|
cellThickness={0.5}
|
||||||
cellColor="#1e293b"
|
cellColor="#2a2a3a"
|
||||||
sectionSize={5}
|
sectionSize={5}
|
||||||
sectionThickness={1}
|
sectionThickness={1}
|
||||||
sectionColor="#334155"
|
sectionColor="#3b3b4b"
|
||||||
fadeDistance={50}
|
fadeDistance={50}
|
||||||
fadeStrength={1}
|
fadeStrength={1}
|
||||||
followCamera={false}
|
followCamera={false}
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Render discovered blocks */}
|
{/* Render discovered blocks with chunked rendering */}
|
||||||
<WorldBlocks blocks={worldBlocks} />
|
<WorldBlocks
|
||||||
|
blocks={worldBlocks}
|
||||||
|
interactionMode={interactionMode}
|
||||||
|
selectedTurtleId={selectedTurtleId}
|
||||||
|
onBlockClick={handleBlockClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Interaction mode previews */}
|
||||||
|
{moveTarget && <MoveTargetPreview position={moveTarget} />}
|
||||||
|
{buildPreview && <BuildPreview position={buildPreview} />}
|
||||||
|
{(selectionStart || selectionEnd) && (
|
||||||
|
<SelectionArea start={selectionStart} end={selectionEnd || selectionStart} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mining areas */}
|
{/* Mining areas */}
|
||||||
{miningAreas.map((area) => {
|
{miningAreas.map((area) => {
|
||||||
@@ -932,12 +1297,15 @@ function Scene() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Players */}
|
{/* Players */}
|
||||||
{players.map((player) => (
|
{players
|
||||||
<PlayerMarker key={player.playerID} player={player} />
|
.filter((p) => p.position && Date.now() - (p.timestamp || 0) < 30000)
|
||||||
))}
|
.map((player) => (
|
||||||
|
<PlayerMarker key={player.playerID} player={player} />
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Camera controls */}
|
{/* Camera controls */}
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
ref={controlsRef}
|
||||||
target={centerPoint}
|
target={centerPoint}
|
||||||
enableDamping
|
enableDamping
|
||||||
dampingFactor={0.05}
|
dampingFactor={0.05}
|
||||||
@@ -948,16 +1316,143 @@ function Scene() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Map3D component
|
// Interaction mode toolbar overlay
|
||||||
export default function Map3D() {
|
function InteractionToolbar({ mode, setMode, lastInteraction }) {
|
||||||
|
const modes = [
|
||||||
|
{ key: INTERACTION_MODE.LOOK, icon: '👁️', label: 'Look', desc: 'Orbit camera' },
|
||||||
|
{ key: INTERACTION_MODE.MOVE, icon: '🎯', label: 'Move', desc: 'Click to send turtle' },
|
||||||
|
{ key: INTERACTION_MODE.BUILD, icon: '🧱', label: 'Build', desc: 'Click face to preview' },
|
||||||
|
{ key: INTERACTION_MODE.SELECT, icon: '⬜', label: 'Select', desc: 'Click two corners' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: '100%', background: '#0a0e1a' }}>
|
<div style={{
|
||||||
<Canvas
|
position: 'absolute',
|
||||||
camera={{ position: [15, 15, 15], fov: 60 }}
|
bottom: '16px',
|
||||||
style={{ background: '#0a0e1a' }}
|
left: '50%',
|
||||||
>
|
transform: 'translateX(-50%)',
|
||||||
<Scene />
|
display: 'flex',
|
||||||
</Canvas>
|
gap: '4px',
|
||||||
|
background: '#3b3b3b',
|
||||||
|
padding: '6px',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a, 0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 10,
|
||||||
|
fontFamily: "'Silkscreen', 'Courier New', monospace",
|
||||||
|
}}>
|
||||||
|
{modes.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => setMode(m.key)}
|
||||||
|
title={m.desc}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
border: `2px solid ${mode === m.key ? '#4a8c2a' : '#1a1a1a'}`,
|
||||||
|
background: mode === m.key ? '#4a8c2a' : '#6b6b6b',
|
||||||
|
color: mode === m.key ? '#ffffff' : '#e0e0e0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: "'Silkscreen', 'Courier New', monospace",
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
transition: 'all 0.1s',
|
||||||
|
textShadow: '1px 1px 0 #1a1a1a',
|
||||||
|
boxShadow: mode === m.key
|
||||||
|
? 'inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a'
|
||||||
|
: 'inset 0 2px 0 #888, inset 0 -2px 0 #444',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>{m.icon}</span>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{lastInteraction && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginBottom: '8px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: '#3b3b3b',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: "'Silkscreen', 'Courier New', monospace",
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textShadow: '1px 1px 0 #1a1a1a',
|
||||||
|
}}>
|
||||||
|
{lastInteraction.mode === 'move' && `🎯 Moving turtle to (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
|
||||||
|
{lastInteraction.mode === 'build' && `🧱 Build at (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
|
||||||
|
{lastInteraction.mode === 'select' && lastInteraction.end &&
|
||||||
|
`⬜ Selected (${lastInteraction.start.x},${lastInteraction.start.y},${lastInteraction.start.z}) → (${lastInteraction.end.x},${lastInteraction.end.y},${lastInteraction.end.z})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block count HUD
|
||||||
|
function BlockCountHUD({ blocks }) {
|
||||||
|
const count = blocks.length;
|
||||||
|
const chunkCount = useMemo(() => {
|
||||||
|
const chunks = new Set();
|
||||||
|
blocks.forEach(b => chunks.add(`${Math.floor(b.x / 16)},${Math.floor(b.z / 16)}`));
|
||||||
|
return chunks.size;
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#3b3b3b',
|
||||||
|
border: '2px solid #1a1a1a',
|
||||||
|
boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a, 0 2px 8px rgba(0,0,0,0.4)',
|
||||||
|
color: '#55ffff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: "'Silkscreen', 'Courier New', monospace",
|
||||||
|
textShadow: '1px 1px 0 #1a1a1a',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
🧊 {count.toLocaleString()} blocks · 📦 {chunkCount} chunks
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Map3D component
|
||||||
|
export default function Map3D() {
|
||||||
|
const [interactionMode, setInteractionMode] = useState(INTERACTION_MODE.LOOK);
|
||||||
|
const [lastInteraction, setLastInteraction] = useState(null);
|
||||||
|
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
||||||
|
|
||||||
|
const handleInteraction = useCallback((event) => {
|
||||||
|
setLastInteraction(event);
|
||||||
|
// Clear notification after 5 seconds
|
||||||
|
setTimeout(() => setLastInteraction(null), 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%', background: '#1a1a2e', position: 'relative' }}>
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [15, 15, 15], fov: 60 }}
|
||||||
|
shadows
|
||||||
|
style={{ background: '#1a1a2e' }}
|
||||||
|
>
|
||||||
|
<fog attach="fog" args={['#1a1a2e', 60, 120]} />
|
||||||
|
<Scene interactionMode={interactionMode} onInteraction={handleInteraction} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<BlockCountHUD blocks={worldBlocks} />
|
||||||
|
<InteractionToolbar
|
||||||
|
mode={interactionMode}
|
||||||
|
setMode={setInteractionMode}
|
||||||
|
lastInteraction={lastInteraction}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Mining Areas Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.mining-areas-panel {
|
.mining-areas-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
color: #e2e8f0;
|
color: #e0e0e0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -12,79 +17,86 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: #1e293b;
|
background: #6b4e28;
|
||||||
border-bottom: 2px solid #334155;
|
border-bottom: 3px solid #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h2 {
|
.panel-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #f8fafc;
|
color: #ffff55;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-create {
|
.btn-create {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-create:hover {
|
.btn-create:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter buttons */
|
/* Filter buttons */
|
||||||
.filter-buttons {
|
.filter-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #1a1a1a;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #334155;
|
background: #6b6b6b;
|
||||||
color: #94a3b8;
|
color: #e0e0e0;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
background: #475569;
|
background: #7b7b7b;
|
||||||
color: #e2e8f0;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create form */
|
/* Create form */
|
||||||
.create-form {
|
.create-form {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #1a1a1a;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form h3 {
|
.create-form h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #f8fafc;
|
color: #ffaa00;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
@@ -94,28 +106,27 @@
|
|||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #cbd5e1;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.form-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 6px;
|
color: #e0e0e0;
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group select:focus {
|
.form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-section {
|
.coordinates-section {
|
||||||
@@ -130,31 +141,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-header label {
|
.coordinates-header label {
|
||||||
color: #cbd5e1;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position {
|
.btn-use-position {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position:hover:not(:disabled) {
|
.btn-use-position:hover:not(:disabled) {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position:disabled {
|
.btn-use-position:disabled {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-inputs {
|
.coordinate-inputs {
|
||||||
@@ -170,21 +184,22 @@
|
|||||||
.btn-submit {
|
.btn-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:hover {
|
.btn-submit:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Areas list */
|
/* Areas list */
|
||||||
@@ -197,7 +212,7 @@
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -206,22 +221,21 @@
|
|||||||
|
|
||||||
/* Area card */
|
/* Area card */
|
||||||
.area-card {
|
.area-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-card:hover {
|
.area-card:hover {
|
||||||
border-color: #475569;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-card.has-conflict {
|
.area-card.has-conflict {
|
||||||
border-color: #ef4444;
|
border-color: #ff5555;
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
|
background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-header {
|
.area-header {
|
||||||
@@ -230,23 +244,24 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-header h3 {
|
.area-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #f8fafc;
|
color: #e0e0e0;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 12px;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Area info */
|
/* Area info */
|
||||||
@@ -259,7 +274,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row:last-child {
|
.info-row:last-child {
|
||||||
@@ -267,32 +282,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
color: #e2e8f0;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-value {
|
.coordinate-value {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Conflict warning */
|
/* Conflict warning */
|
||||||
.conflict-warning {
|
.conflict-warning {
|
||||||
background: #7f1d1d;
|
background: #6b1a1a44;
|
||||||
border: 1px solid #ef4444;
|
border: 2px solid #ff5555;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #fca5a5;
|
color: #ff5555;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,46 +331,45 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start {
|
.btn-start {
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start:hover {
|
.btn-start:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-complete {
|
.btn-complete {
|
||||||
background: #3b82f6;
|
background: #345ec3;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-complete:hover {
|
.btn-complete:hover {
|
||||||
background: #2563eb;
|
background: #4a6ed3;
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
@@ -366,18 +380,18 @@
|
|||||||
|
|
||||||
.areas-list::-webkit-scrollbar-track,
|
.areas-list::-webkit-scrollbar-track,
|
||||||
.create-form::-webkit-scrollbar-track {
|
.create-form::-webkit-scrollbar-track {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areas-list::-webkit-scrollbar-thumb,
|
.areas-list::-webkit-scrollbar-thumb,
|
||||||
.create-form::-webkit-scrollbar-thumb {
|
.create-form::-webkit-scrollbar-thumb {
|
||||||
background: #334155;
|
background: #5a5a5a;
|
||||||
border-radius: 4px;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areas-list::-webkit-scrollbar-thumb:hover,
|
.areas-list::-webkit-scrollbar-thumb:hover,
|
||||||
.create-form::-webkit-scrollbar-thumb:hover {
|
.create-form::-webkit-scrollbar-thumb:hover {
|
||||||
background: #475569;
|
background: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@@ -432,3 +446,62 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker row */
|
||||||
|
.color-picker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.active {
|
||||||
|
border-color: #ffff55;
|
||||||
|
box-shadow: 0 0 4px #ffff55;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color dot in area card */
|
||||||
|
.area-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-color-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [newArea, setNewArea] = useState({
|
const [newArea, setNewArea] = useState({
|
||||||
areaName: '',
|
areaName: '',
|
||||||
|
color: '#4a8c2a',
|
||||||
startX: '',
|
startX: '',
|
||||||
startY: '',
|
startY: '',
|
||||||
startZ: '',
|
startZ: '',
|
||||||
@@ -66,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
endX: Number(newArea.endX),
|
endX: Number(newArea.endX),
|
||||||
endY: Number(newArea.endY),
|
endY: Number(newArea.endY),
|
||||||
endZ: Number(newArea.endZ),
|
endZ: Number(newArea.endZ),
|
||||||
|
color: newArea.color,
|
||||||
status: 'planned'
|
status: 'planned'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -74,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewArea({
|
setNewArea({
|
||||||
areaName: '',
|
areaName: '',
|
||||||
|
color: '#4a8c2a',
|
||||||
startX: '',
|
startX: '',
|
||||||
startY: '',
|
startY: '',
|
||||||
startZ: '',
|
startZ: '',
|
||||||
@@ -192,12 +195,12 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
// Status badge component
|
// Status badge component
|
||||||
const StatusBadge = ({ status }) => {
|
const StatusBadge = ({ status }) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
planned: '#6366f1',
|
planned: '#345ec3',
|
||||||
mining: '#f59e0b',
|
mining: '#ffaa00',
|
||||||
completed: '#10b981'
|
completed: '#4a8c2a'
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}>
|
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -259,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Area Color:</label>
|
||||||
|
<div className="color-picker-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newArea.color}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
|
||||||
|
className="color-input"
|
||||||
|
/>
|
||||||
|
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => setNewArea({ ...newArea, color: c })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Assign Turtle:</label>
|
<label>Assign Turtle:</label>
|
||||||
<select
|
<select
|
||||||
@@ -364,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
return (
|
return (
|
||||||
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||||
<div className="area-header">
|
<div className="area-header">
|
||||||
<h3>{area.areaName}</h3>
|
<div className="area-title-row">
|
||||||
|
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
|
||||||
|
<h3>{area.areaName}</h3>
|
||||||
|
</div>
|
||||||
<StatusBadge status={area.status} />
|
<StatusBadge status={area.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Path Recorder
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.path-recorder {
|
.path-recorder {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recorder-header {
|
.recorder-header {
|
||||||
@@ -18,23 +22,23 @@
|
|||||||
.recorder-header h2 {
|
.recorder-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-turtle-badge {
|
.selected-turtle-badge {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #1e293b;
|
background: #2d6b1a33;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #55ff55;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border: 1px solid #10b981;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -42,21 +46,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.info {
|
.message.info {
|
||||||
background: #3b82f633;
|
background: #1a4a6b33;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
border: 1px solid #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -72,17 +76,19 @@
|
|||||||
|
|
||||||
/* Recording Section */
|
/* Recording Section */
|
||||||
.recording-section {
|
.recording-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-section h3 {
|
.recording-section h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #e0e0e0;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-form {
|
.record-form {
|
||||||
@@ -94,36 +100,36 @@
|
|||||||
.record-form input,
|
.record-form input,
|
||||||
.record-form textarea {
|
.record-form textarea {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-form input:focus,
|
.record-form input:focus,
|
||||||
.record-form textarea:focus {
|
.record-form textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn {
|
.record-btn {
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.875rem 1.5rem;
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn:hover:not(:disabled) {
|
.record-btn:hover:not(:disabled) {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #ff4444, inset 0 -2px 0 #880000;
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn:disabled {
|
.record-btn:disabled {
|
||||||
@@ -141,7 +147,8 @@
|
|||||||
.waypoint-counter {
|
.waypoint-counter {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,51 +164,53 @@
|
|||||||
.recording-info {
|
.recording-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-info p {
|
.recording-info p {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-info strong {
|
.recording-info strong {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-btn {
|
.stop-btn {
|
||||||
padding: 0.875rem 2rem;
|
padding: 0.875rem 2rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-btn:hover {
|
.stop-btn:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paths Section */
|
/* Paths Section */
|
||||||
.paths-section h3 {
|
.paths-section h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ffff;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +221,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.path-card {
|
.path-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-card:hover {
|
.path-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-card-header {
|
.path-card-header {
|
||||||
@@ -230,13 +239,13 @@
|
|||||||
.path-info h4 {
|
.path-info h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-description {
|
.path-description {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -246,7 +255,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,44 +272,45 @@
|
|||||||
|
|
||||||
.path-actions button {
|
.path-actions button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
background: #3b82f6;
|
background: #345ec3;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn:hover {
|
.view-btn:hover {
|
||||||
background: #2563eb;
|
background: #4a6ed3;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.play-btn {
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn:hover {
|
.play-btn:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -318,13 +328,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Path Details Modal */
|
/* Path Details Modal */
|
||||||
@@ -334,7 +344,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -353,13 +363,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.75rem;
|
border: 3px solid #1a1a1a;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
animation: slideUp 0.3s;
|
animation: slideUp 0.3s;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
@@ -378,30 +389,34 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
|
background: #6b4e28;
|
||||||
|
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: transparent;
|
background: #aa0000;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
color: #94a3b8;
|
color: white;
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #e5e7eb;
|
background: #cc0000;
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -409,7 +424,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-description {
|
.modal-description {
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -418,8 +433,9 @@
|
|||||||
.path-visualization h4 {
|
.path-visualization h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoints-grid {
|
.waypoints-grid {
|
||||||
@@ -434,8 +450,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,21 +461,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 50%;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-coords {
|
.waypoint-coords {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-action {
|
.waypoint-action {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,25 +487,26 @@
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat .stat-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat .stat-value {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [playingBack, setPlayingBack] = useState(false);
|
const [playingBack, setPlayingBack] = useState(false);
|
||||||
|
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPaths();
|
loadPaths();
|
||||||
@@ -150,44 +150,23 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPlayingBack(true);
|
setPlayingBack(true);
|
||||||
showMessage(`Playing back ${waypoints.length} waypoints...`, 'info');
|
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
|
||||||
|
|
||||||
const turtleId = selectedTurtle.turtleID;
|
const turtleId = selectedTurtle.turtleID;
|
||||||
|
|
||||||
// Determine movement commands between consecutive waypoints
|
// Use server-side pathfinding to navigate to each waypoint sequentially
|
||||||
for (let i = 1; i < waypoints.length; i++) {
|
for (let i = 0; i < waypoints.length; i++) {
|
||||||
const prev = waypoints[i - 1];
|
const wp = waypoints[i];
|
||||||
const curr = waypoints[i];
|
|
||||||
|
|
||||||
const dx = curr.x - prev.x;
|
// Navigate to each waypoint using the server's moving state
|
||||||
const dy = curr.y - prev.y;
|
await setTurtleState(turtleId, 'moving', { target: { x: wp.x, y: wp.y, z: wp.z } });
|
||||||
const dz = curr.z - prev.z;
|
|
||||||
|
// Wait for turtle to arrive (poll position or just add delay)
|
||||||
// Vertical movement
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
if (dy > 0) {
|
|
||||||
for (let j = 0; j < dy; j++) sendCommand(turtleId, 'up');
|
|
||||||
} else if (dy < 0) {
|
|
||||||
for (let j = 0; j < Math.abs(dy); j++) sendCommand(turtleId, 'down');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal movement - send as forward movements with turns
|
|
||||||
if (dx > 0) {
|
|
||||||
for (let j = 0; j < dx; j++) sendCommand(turtleId, 'forward');
|
|
||||||
} else if (dx < 0) {
|
|
||||||
for (let j = 0; j < Math.abs(dx); j++) sendCommand(turtleId, 'forward');
|
|
||||||
}
|
|
||||||
if (dz > 0) {
|
|
||||||
for (let j = 0; j < dz; j++) sendCommand(turtleId, 'forward');
|
|
||||||
} else if (dz < 0) {
|
|
||||||
for (let j = 0; j < Math.abs(dz); j++) sendCommand(turtleId, 'forward');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay between waypoint groups to avoid flooding
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlayingBack(false);
|
setPlayingBack(false);
|
||||||
showMessage('Playback complete! Commands queued for turtle.', 'success');
|
showMessage('Playback complete! Turtle navigating via server.', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const showMessage = (text, type) => {
|
const showMessage = (text, type) => {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Stats Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.stats-panel {
|
.stats-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-header {
|
.stats-header {
|
||||||
@@ -18,43 +22,47 @@
|
|||||||
.stats-header h2 {
|
.stats-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter {
|
.time-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button {
|
.time-filter button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: transparent;
|
background: #6b6b6b;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #94a3b8;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button:hover {
|
.time-filter button:hover {
|
||||||
color: #e5e7eb;
|
background: #7b7b7b;
|
||||||
background: #334155;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button.active {
|
.time-filter button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,33 +74,36 @@
|
|||||||
.turtle-stats h3 {
|
.turtle-stats h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ff55;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-section {
|
.stat-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-mined {
|
.total-mined {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -101,7 +112,7 @@
|
|||||||
.blocks-grid {
|
.blocks-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-stat {
|
.block-stat {
|
||||||
@@ -109,14 +120,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-stat:hover {
|
.block-stat:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 -1px 0 #333, inset 0 1px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-emoji {
|
.block-emoji {
|
||||||
@@ -131,13 +143,13 @@
|
|||||||
.block-name {
|
.block-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-count {
|
.block-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +161,16 @@
|
|||||||
.leaderboard h3 {
|
.leaderboard h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-list {
|
.leaderboard-list {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item {
|
.leaderboard-item {
|
||||||
@@ -165,9 +179,10 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item:last-child {
|
.leaderboard-item:last-child {
|
||||||
@@ -175,20 +190,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item:hover {
|
.leaderboard-item:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-1 {
|
.leaderboard-item.rank-1 {
|
||||||
border-left: 3px solid #fbbf24;
|
border-left: 4px solid #ffaa00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-2 {
|
.leaderboard-item.rank-2 {
|
||||||
border-left: 3px solid #9ca3af;
|
border-left: 4px solid #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-3 {
|
.leaderboard-item.rank-3 {
|
||||||
border-left: 3px solid #cd7f32;
|
border-left: 4px solid #cd7f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank {
|
.rank {
|
||||||
@@ -196,6 +210,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #ffff55;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-info {
|
.miner-info {
|
||||||
@@ -205,27 +221,29 @@
|
|||||||
.miner-name {
|
.miner-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-stats {
|
.miner-stats {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-score {
|
.miner-score {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All Turtles Overview */
|
/* All Turtles Overview */
|
||||||
.all-turtles-stats h3 {
|
.all-turtles-stats h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ffff;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtles-summary-grid {
|
.turtles-summary-grid {
|
||||||
@@ -235,16 +253,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-card {
|
.turtle-summary-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-card:hover {
|
.turtle-summary-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-header {
|
.turtle-summary-header {
|
||||||
@@ -253,19 +271,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-id {
|
.turtle-id {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-total {
|
.turtle-total {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-top-blocks {
|
.turtle-top-blocks {
|
||||||
@@ -278,8 +297,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +309,7 @@
|
|||||||
.mini-count {
|
.mini-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -308,19 +327,19 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Task Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.task-panel {
|
.task-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-header {
|
.task-header {
|
||||||
@@ -16,31 +20,33 @@
|
|||||||
.task-header h2 {
|
.task-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-btn {
|
.create-task-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-btn:hover {
|
.create-task-btn:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -48,15 +54,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -72,18 +78,20 @@
|
|||||||
|
|
||||||
/* Create Task Form */
|
/* Create Task Form */
|
||||||
.create-task-form {
|
.create-task-form {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
animation: slideIn 0.3s;
|
animation: slideIn 0.3s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form h3 {
|
.create-task-form h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form form {
|
.create-task-form form {
|
||||||
@@ -104,28 +112,29 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form select,
|
.create-task-form select,
|
||||||
.create-task-form input[type="text"],
|
.create-task-form input[type="text"],
|
||||||
.create-task-form input[type="number"] {
|
.create-task-form input[type="number"] {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form select:focus,
|
.create-task-form select:focus,
|
||||||
.create-task-form input:focus {
|
.create-task-form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form input[type="range"] {
|
.create-task-form input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
accent-color: #4a8c2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-value {
|
.priority-value {
|
||||||
@@ -137,7 +146,7 @@
|
|||||||
.coordinates-section h4 {
|
.coordinates-section h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,64 +165,68 @@
|
|||||||
.coord-group span {
|
.coord-group span {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coord-group input {
|
.coord-group input {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.25rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:hover {
|
.submit-btn:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task Filters */
|
/* Task Filters */
|
||||||
.task-filters {
|
.task-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button {
|
.task-filters button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #1e293b;
|
background: #6b6b6b;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #94a3b8;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button:hover {
|
.task-filters button:hover {
|
||||||
color: #e5e7eb;
|
background: #7b7b7b;
|
||||||
background: #334155;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button.active {
|
.task-filters button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tasks List */
|
/* Tasks List */
|
||||||
@@ -226,20 +239,20 @@
|
|||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card-header {
|
.task-card-header {
|
||||||
@@ -264,7 +277,7 @@
|
|||||||
.task-type {
|
.task-type {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-badges {
|
.task-badges {
|
||||||
@@ -275,41 +288,42 @@
|
|||||||
.priority-badge,
|
.priority-badge,
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-assignment {
|
.task-assignment {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-parameters {
|
.task-parameters {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-result {
|
.task-result {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #10b98120;
|
background: #2d6b1a20;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #55ff55;
|
||||||
border-left: 3px solid #10b981;
|
border-left: 4px solid #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-actions {
|
.task-actions {
|
||||||
@@ -320,25 +334,26 @@
|
|||||||
|
|
||||||
.task-actions button {
|
.task-actions button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #0f172a;
|
background: #6b6b6b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.25rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-actions button:hover {
|
.task-actions button:hover {
|
||||||
background: #1e293b;
|
background: #7b7b7b;
|
||||||
border-color: #3b82f6;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-timestamp {
|
.task-timestamp {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,13 +372,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -157,19 +157,19 @@ const TaskPanel = ({ turtles, apiUrl }) => {
|
|||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return '#94a3b8';
|
case 'pending': return '#a0a0a0';
|
||||||
case 'in_progress': return '#3b82f6';
|
case 'in_progress': return '#345ec3';
|
||||||
case 'completed': return '#10b981';
|
case 'completed': return '#4a8c2a';
|
||||||
case 'failed': return '#ef4444';
|
case 'failed': return '#aa0000';
|
||||||
default: return '#94a3b8';
|
default: return '#a0a0a0';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityLabel = (priority) => {
|
const getPriorityLabel = (priority) => {
|
||||||
if (priority >= 8) return { label: 'Critical', color: '#ef4444' };
|
if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
|
||||||
if (priority >= 6) return { label: 'High', color: '#f59e0b' };
|
if (priority >= 6) return { label: 'High', color: '#ffaa00' };
|
||||||
if (priority >= 4) return { label: 'Medium', color: '#3b82f6' };
|
if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
|
||||||
return { label: 'Low', color: '#94a3b8' };
|
return { label: 'Low', color: '#a0a0a0' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Voice Control
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.voice-control {
|
.voice-control {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #334155;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-control.unsupported {
|
.voice-control.unsupported {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-control.unsupported small {
|
.voice-control.unsupported small {
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -27,62 +32,67 @@
|
|||||||
.voice-header h3 {
|
.voice-header h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-turtle {
|
.selected-turtle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-selection {
|
.no-selection {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button {
|
.voice-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
background: #345ec3;
|
||||||
border: none;
|
border: 3px solid #1a1a1a;
|
||||||
border-radius: 0.75rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.3s;
|
transition: all 0.1s;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -3px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button:hover:not(:disabled) {
|
.voice-button:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
background: #4a6ed3;
|
||||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
box-shadow: inset 0 2px 0 #6688ee, inset 0 -3px 0 #3344aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button:disabled {
|
.voice-button:disabled {
|
||||||
background: #374151;
|
background: #4b4b4b;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button.listening {
|
.voice-button.listening {
|
||||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
background: #aa0000;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000;
|
||||||
animation: pulse-glow 2s infinite;
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 20px rgba(255, 85, 85, 0.5);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 40px rgba(255, 85, 85, 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +105,6 @@
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.6);
|
border: 3px solid rgba(255, 255, 255, 0.6);
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse-ring 1.5s infinite;
|
animation: pulse-ring 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,29 +122,28 @@
|
|||||||
.voice-feedback {
|
.voice-feedback {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript {
|
.transcript {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript strong {
|
.transcript strong {
|
||||||
color: #60a5fa;
|
color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-command {
|
.last-command {
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-command strong {
|
.last-command strong {
|
||||||
color: #34d399;
|
color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help {
|
.voice-commands-help {
|
||||||
@@ -143,23 +151,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help details {
|
.voice-commands-help details {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help summary {
|
.voice-commands-help summary {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help summary:hover {
|
.voice-commands-help summary:hover {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commands-grid {
|
.commands-grid {
|
||||||
@@ -171,11 +178,12 @@
|
|||||||
|
|
||||||
.command-category h4 {
|
.command-category h4 {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #60a5fa;
|
color: #ffaa00;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-category ul {
|
.command-category ul {
|
||||||
@@ -186,14 +194,14 @@
|
|||||||
|
|
||||||
.command-category li {
|
.command-category li {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-category li::before {
|
.command-category li::before {
|
||||||
content: "▸ ";
|
content: "▸ ";
|
||||||
color: #3b82f6;
|
color: #55ff55;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ export default function VoiceControl() {
|
|||||||
const [recognition, setRecognition] = useState(null);
|
const [recognition, setRecognition] = useState(null);
|
||||||
|
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||||
|
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||||
|
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||||
|
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||||
|
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||||
|
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||||
|
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||||
|
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||||
|
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||||
|
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||||
|
const refuelTurtle = useTurtleStore((state) => state.refuelTurtle);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if speech recognition is supported
|
// Check if speech recognition is supported
|
||||||
@@ -49,52 +60,68 @@ export default function VoiceControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const turtleId = selectedTurtle.turtleID;
|
const turtleId = selectedTurtle.turtleID;
|
||||||
let action = null;
|
let actionName = null;
|
||||||
let param = null;
|
|
||||||
|
|
||||||
// Parse voice commands
|
// Movement commands (server-side)
|
||||||
if (command.includes('forward') || command.includes('go ahead')) {
|
if (command.includes('forward') || command.includes('go ahead')) {
|
||||||
action = 'forward';
|
moveForward(turtleId);
|
||||||
|
actionName = 'forward';
|
||||||
} else if (command.includes('back') || command.includes('backward')) {
|
} else if (command.includes('back') || command.includes('backward')) {
|
||||||
action = 'back';
|
moveBack(turtleId);
|
||||||
|
actionName = 'back';
|
||||||
} else if (command.includes('turn left') || command.includes('left')) {
|
} else if (command.includes('turn left') || command.includes('left')) {
|
||||||
action = 'turnLeft';
|
turnLeft(turtleId);
|
||||||
|
actionName = 'turn left';
|
||||||
} else if (command.includes('turn right') || command.includes('right')) {
|
} else if (command.includes('turn right') || command.includes('right')) {
|
||||||
action = 'turnRight';
|
turnRight(turtleId);
|
||||||
|
actionName = 'turn right';
|
||||||
} else if (command.includes('go up') || command.includes('move up')) {
|
} else if (command.includes('go up') || command.includes('move up')) {
|
||||||
action = 'up';
|
moveUp(turtleId);
|
||||||
|
actionName = 'up';
|
||||||
} else if (command.includes('go down') || command.includes('move down')) {
|
} else if (command.includes('go down') || command.includes('move down')) {
|
||||||
action = 'down';
|
moveDown(turtleId);
|
||||||
|
actionName = 'down';
|
||||||
} else if (command.includes('dig')) {
|
} else if (command.includes('dig')) {
|
||||||
if (command.includes('up')) {
|
if (command.includes('up')) {
|
||||||
action = 'digUp';
|
digBlockUp(turtleId);
|
||||||
|
actionName = 'dig up';
|
||||||
} else if (command.includes('down')) {
|
} else if (command.includes('down')) {
|
||||||
action = 'digDown';
|
digBlockDown(turtleId);
|
||||||
|
actionName = 'dig down';
|
||||||
} else {
|
} else {
|
||||||
action = 'dig';
|
digBlock(turtleId);
|
||||||
|
actionName = 'dig';
|
||||||
}
|
}
|
||||||
} else if (command.includes('place') || command.includes('build')) {
|
} else if (command.includes('place') || command.includes('build')) {
|
||||||
action = 'place';
|
placeBlock(turtleId);
|
||||||
|
actionName = 'place';
|
||||||
|
// State machine commands (server-side)
|
||||||
} else if (command.includes('explore') || command.includes('start exploring')) {
|
} else if (command.includes('explore') || command.includes('start exploring')) {
|
||||||
action = 'explore';
|
setTurtleState(turtleId, 'exploring');
|
||||||
|
actionName = 'explore';
|
||||||
} else if (command.includes('mine') || command.includes('start mining')) {
|
} else if (command.includes('mine') || command.includes('start mining')) {
|
||||||
action = 'mine';
|
setTurtleState(turtleId, 'mining');
|
||||||
|
actionName = 'mine';
|
||||||
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
||||||
action = 'returnHome';
|
setTurtleState(turtleId, 'goHome');
|
||||||
|
actionName = 'go home';
|
||||||
} else if (command.includes('stop')) {
|
} else if (command.includes('stop')) {
|
||||||
action = 'stop';
|
setTurtleState(turtleId, 'idle');
|
||||||
} else if (command.includes('set home') || command.includes('mark home')) {
|
actionName = 'idle';
|
||||||
action = 'setHome';
|
|
||||||
} else if (command.includes('refuel')) {
|
} else if (command.includes('refuel')) {
|
||||||
action = 'refuel';
|
setTurtleState(turtleId, 'refueling');
|
||||||
} else if (command.includes('status') || command.includes('report')) {
|
actionName = 'refuel';
|
||||||
action = 'status';
|
} else if (command.includes('farm')) {
|
||||||
|
setTurtleState(turtleId, 'farming');
|
||||||
|
actionName = 'farm';
|
||||||
|
} else if (command.includes('dump')) {
|
||||||
|
setTurtleState(turtleId, 'dumpInventory');
|
||||||
|
actionName = 'dump inventory';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action) {
|
if (actionName) {
|
||||||
sendCommand(turtleId, action, param);
|
setLastCommand(actionName);
|
||||||
setLastCommand(`${action}${param ? ` ${param}` : ''}`);
|
speak(`Sending ${actionName} command`);
|
||||||
speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`);
|
|
||||||
} else {
|
} else {
|
||||||
speak('Command not recognized');
|
speak('Command not recognized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -5,13 +7,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
-webkit-font-smoothing: none;
|
||||||
sans-serif;
|
-moz-osx-font-smoothing: unset;
|
||||||
-webkit-font-smoothing: antialiased;
|
image-rendering: pixelated;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
background: #2c2c2c;
|
||||||
background: #0a0e1a;
|
color: #d4d4d4;
|
||||||
color: #e0e0e0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,5 +22,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
turtles: {},
|
turtles: {},
|
||||||
players: {},
|
players: {},
|
||||||
worldBlocks: [],
|
worldBlocks: [],
|
||||||
|
chunkAnalyses: {},
|
||||||
selectedTurtleId: null,
|
selectedTurtleId: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
ws: null,
|
ws: null,
|
||||||
@@ -34,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
data.turtles.forEach(turtle => {
|
data.turtles.forEach(turtle => {
|
||||||
turtlesMap[turtle.turtleID] = turtle;
|
turtlesMap[turtle.turtleID] = turtle;
|
||||||
});
|
});
|
||||||
|
const playersMap = {};
|
||||||
|
if (data.players && Array.isArray(data.players)) {
|
||||||
|
data.players.forEach(player => {
|
||||||
|
playersMap[player.playerID] = player;
|
||||||
|
});
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
turtles: turtlesMap,
|
turtles: turtlesMap,
|
||||||
|
players: playersMap,
|
||||||
worldBlocks: data.blocks || []
|
worldBlocks: data.blocks || []
|
||||||
});
|
});
|
||||||
} else if (data.type === 'turtle_update') {
|
} else if (data.type === 'turtle_update') {
|
||||||
@@ -73,7 +81,8 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
[data.playerID]: {
|
[data.playerID]: {
|
||||||
playerID: data.playerID,
|
playerID: data.playerID,
|
||||||
position: data.position,
|
position: data.position,
|
||||||
timestamp: data.timestamp
|
label: data.label || null,
|
||||||
|
timestamp: data.timestamp || Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -97,6 +106,52 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
return { worldBlocks: Array.from(blockMap.values()) };
|
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);
|
||||||
@@ -169,35 +224,6 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
set({ worldBlocks: Array.from(blockMap.values()) });
|
set({ worldBlocks: Array.from(blockMap.values()) });
|
||||||
},
|
},
|
||||||
|
|
||||||
sendCommand: async (turtleId, command, param = null) => {
|
|
||||||
const { ws } = get();
|
|
||||||
|
|
||||||
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${JSON.stringify(param)})` : '');
|
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'command',
|
|
||||||
turtleID: turtleId,
|
|
||||||
command,
|
|
||||||
param
|
|
||||||
}));
|
|
||||||
console.log(' ✅ Sent via WebSocket');
|
|
||||||
} else {
|
|
||||||
// Fallback to REST API
|
|
||||||
console.log(' ⚠️ WebSocket not connected, using REST API fallback');
|
|
||||||
try {
|
|
||||||
await fetch(`${API_URL}/turtle/${turtleId}/command`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ command, param })
|
|
||||||
});
|
|
||||||
console.log(' ✅ Sent via REST API');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(' ❌ Error sending command:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set turtle state machine state via REST API
|
// Set turtle state machine state via REST API
|
||||||
setTurtleState: async (turtleId, stateName, stateData = {}) => {
|
setTurtleState: async (turtleId, stateName, stateData = {}) => {
|
||||||
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
|
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
|
||||||
@@ -241,6 +267,245 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== Server-Side Movement & Actions ==========
|
||||||
|
// All movement/actions are routed through the server's Turtle.js exec() pipeline
|
||||||
|
|
||||||
|
_turtleAction: async (turtleId, action, body = {}) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Error ${action}:`, error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
moveForward: async (turtleId) => get()._turtleAction(turtleId, 'forward'),
|
||||||
|
moveBack: async (turtleId) => get()._turtleAction(turtleId, 'back'),
|
||||||
|
moveUp: async (turtleId) => get()._turtleAction(turtleId, 'up'),
|
||||||
|
moveDown: async (turtleId) => get()._turtleAction(turtleId, 'down'),
|
||||||
|
turnLeft: async (turtleId) => get()._turtleAction(turtleId, 'turnLeft'),
|
||||||
|
turnRight: async (turtleId) => get()._turtleAction(turtleId, 'turnRight'),
|
||||||
|
digBlock: async (turtleId) => get()._turtleAction(turtleId, 'dig'),
|
||||||
|
digBlockUp: async (turtleId) => get()._turtleAction(turtleId, 'digUp'),
|
||||||
|
digBlockDown: async (turtleId) => get()._turtleAction(turtleId, 'digDown'),
|
||||||
|
placeBlock: async (turtleId, text) => get()._turtleAction(turtleId, 'place', { text }),
|
||||||
|
placeBlockUp: async (turtleId, text) => get()._turtleAction(turtleId, 'placeUp', { text }),
|
||||||
|
placeBlockDown: async (turtleId, text) => get()._turtleAction(turtleId, 'placeDown', { text }),
|
||||||
|
refuelTurtle: async (turtleId, count) => get()._turtleAction(turtleId, 'refuel-action', { count }),
|
||||||
|
|
||||||
|
// Fetch chunk analyses from server
|
||||||
|
fetchChunkAnalyses: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/chunks`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const analyses = {};
|
||||||
|
data.forEach(c => { analyses[`${c.x},${c.z}`] = c; });
|
||||||
|
set({ chunkAnalyses: analyses });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(' ❌ Error fetching chunks:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request chunk analysis for a specific chunk
|
||||||
|
analyzeChunk: async (chunkX, chunkZ) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/chunks/${chunkX}/${chunkZ}/analyze`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
set(state => ({
|
||||||
|
chunkAnalyses: {
|
||||||
|
...state.chunkAnalyses,
|
||||||
|
[`${chunkX},${chunkZ}`]: data
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(' ❌ Error analyzing chunk:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search blocks by name pattern
|
||||||
|
searchBlocks: async (namePattern) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/world/blocks/search?name=${encodeURIComponent(namePattern)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(' ❌ Error searching blocks:', error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rename a turtle
|
||||||
|
renameTurtle: async (turtleId, name) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(' ❌ Error renaming turtle:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Equip left
|
||||||
|
equipLeft: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-left`, { method: 'POST' });
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Equip right
|
||||||
|
equipRight: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-right`, { method: 'POST' });
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select inventory slot
|
||||||
|
selectSlot: async (turtleId, slot) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/select`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transfer items between slots
|
||||||
|
transferItems: async (turtleId, fromSlot, toSlot, count) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fromSlot, toSlot, count })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sort/compact inventory
|
||||||
|
sortInventory: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/sort`, { method: 'POST' });
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Connect to adjacent inventory
|
||||||
|
connectToInventory: async (turtleId, side) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/connect-inventory`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ side })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drop items
|
||||||
|
dropItems: async (turtleId, direction = 'front', count) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/drop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ direction, count })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Suck items
|
||||||
|
suckItems: async (turtleId, direction = 'front', count) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/suck`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ direction, count })
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update turtle config
|
||||||
|
updateTurtleConfig: async (turtleId, config) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get turtle config
|
||||||
|
getTurtleConfig: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Explore (batched 3-direction inspect)
|
||||||
|
exploreTurtle: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/explore`, { method: 'POST' });
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// GPS locate
|
||||||
|
gpsLocateTurtle: async (turtleId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/gps`, { method: 'POST' });
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Helper getters
|
// Helper getters
|
||||||
getTurtleArray: () => {
|
getTurtleArray: () => {
|
||||||
return Object.values(get().turtles);
|
return Object.values(get().turtles);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Backend server
|
# Backend server
|
||||||
server:
|
server:
|
||||||
@@ -8,12 +6,12 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: turtle-server
|
container_name: turtle-server
|
||||||
ports:
|
ports:
|
||||||
- "4200:3001" # HTTP API
|
- "4200:3001" # HTTP API + WebSocket (unified)
|
||||||
- "3002:3002" # WebSocket
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- WS_PORT=3002
|
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- turtle-network
|
- turtle-network
|
||||||
|
|||||||
36
etc/apps.db
Normal file
36
etc/apps.db
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
[ "rt_turtle_controller" ] = {
|
||||||
|
title = "Turtle Controller",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "turtle.lua",
|
||||||
|
requires = "turtle",
|
||||||
|
},
|
||||||
|
[ "rt_gps_host" ] = {
|
||||||
|
title = "GPS Host",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "gpshost.lua",
|
||||||
|
},
|
||||||
|
[ "rt_web_bridge" ] = {
|
||||||
|
title = "Web Bridge",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "webbridge.lua",
|
||||||
|
},
|
||||||
|
[ "rt_pocket_control" ] = {
|
||||||
|
title = "Pocket Control",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "pocketcontrol.lua",
|
||||||
|
requires = "pocket",
|
||||||
|
},
|
||||||
|
[ "rt_pocket_remote" ] = {
|
||||||
|
title = "Pocket Remote",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "pocketremote.lua",
|
||||||
|
requires = "pocket",
|
||||||
|
},
|
||||||
|
[ "rt_pocket_gps" ] = {
|
||||||
|
title = "Pocket GPS",
|
||||||
|
category = "RemoteTurtle",
|
||||||
|
run = "pocketgps.lua",
|
||||||
|
requires = "pocket",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||||
|
|
||||||
local CHANNEL_SEND = 100
|
local Channels = require('platform.channels')
|
||||||
local CHANNEL_RECEIVE = 101
|
|
||||||
local STATUS_CHANNEL = 102
|
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||||
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
|
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||||
|
|
||||||
-- Find modem
|
-- Find modem
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
@@ -19,9 +21,12 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
local WebBridge = require('platform.webbridge')
|
||||||
modem.open(STATUS_CHANNEL)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(POCKET_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
'remoteturtle.pocket',
|
||||||
|
})
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
|
|
||||||
@@ -98,10 +103,10 @@ local function updateMyPosition()
|
|||||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
type = "player_position",
|
type = "player_position",
|
||||||
playerID = os.getComputerID(),
|
playerID = os.getComputerID(),
|
||||||
|
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
|
||||||
position = myPosition,
|
position = myPosition,
|
||||||
timestamp = os.epoch("utc")
|
timestamp = os.epoch("utc")
|
||||||
})
|
})
|
||||||
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
|
|
||||||
return true
|
return true
|
||||||
else
|
else
|
||||||
addLog("GPS: Failed to locate", colors.red)
|
addLog("GPS: Failed to locate", colors.red)
|
||||||
@@ -556,7 +561,7 @@ parallel.waitForAny(
|
|||||||
function()
|
function()
|
||||||
-- GPS update loop
|
-- GPS update loop
|
||||||
while true do
|
while true do
|
||||||
sleep(5)
|
sleep(2)
|
||||||
updateMyPosition()
|
updateMyPosition()
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
@@ -574,7 +579,9 @@ parallel.waitForAny(
|
|||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||||
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||||
if message.type == "status" then
|
if message.type == "status" then
|
||||||
-- Update turtle list
|
-- Update turtle list
|
||||||
local found = false
|
local found = false
|
||||||
@@ -590,7 +597,7 @@ parallel.waitForAny(
|
|||||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif channel == POCKET_CHANNEL and type(message) == "table" then
|
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||||
-- Handle responses from webbridge
|
-- Handle responses from webbridge
|
||||||
if message.type == "webbridge_status" then
|
if message.type == "webbridge_status" then
|
||||||
webbridgeStatus = message.data
|
webbridgeStatus = message.data
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
-- Live GPS Tracker for Pocket Computer
|
-- Live GPS Tracker for Pocket Computer
|
||||||
-- Shows your current location in real-time
|
-- Shows your current location in real-time
|
||||||
|
|
||||||
local STATUS_CHANNEL = 102
|
local Channels = require('platform.channels')
|
||||||
|
local WebBridge = require('platform.webbridge')
|
||||||
|
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
|
||||||
-- Setup modem
|
-- Setup modem
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
@@ -15,7 +18,7 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(STATUS_CHANNEL)
|
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
local myID = os.getComputerID()
|
local myID = os.getComputerID()
|
||||||
@@ -228,7 +231,9 @@ local function main()
|
|||||||
local replyChannel = param3
|
local replyChannel = param3
|
||||||
local message = param4
|
local message = param4
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (102) and target (4212) channels during migration.
|
||||||
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||||
handleStatus(message)
|
handleStatus(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
119
pocketremote.lua
119
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,8 +18,10 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(STATUS_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
})
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
|
|
||||||
@@ -244,8 +249,8 @@ local function drawDetail()
|
|||||||
print(" Empty")
|
print(" Empty")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Action buttons
|
-- Action buttons (row 1: explore/home/stop)
|
||||||
local btnY = h - 7
|
local btnY = h - 10
|
||||||
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
||||||
sendCommand(turtle.turtleID, "explore")
|
sendCommand(turtle.turtleID, "explore")
|
||||||
end, colors.green)
|
end, colors.green)
|
||||||
@@ -258,7 +263,8 @@ local function drawDetail()
|
|||||||
sendCommand(turtle.turtleID, "stop")
|
sendCommand(turtle.turtleID, "stop")
|
||||||
end, colors.red)
|
end, colors.red)
|
||||||
|
|
||||||
btnY = h - 4
|
-- Row 2: manual/modes/setHome
|
||||||
|
btnY = h - 7
|
||||||
addButton(1, btnY, 8, 2, "MANUAL", function()
|
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||||
viewMode = "manual"
|
viewMode = "manual"
|
||||||
sendCommand(turtle.turtleID, "manual")
|
sendCommand(turtle.turtleID, "manual")
|
||||||
@@ -272,6 +278,45 @@ local function drawDetail()
|
|||||||
sendCommand(turtle.turtleID, "setHome")
|
sendCommand(turtle.turtleID, "setHome")
|
||||||
end, colors.blue)
|
end, colors.blue)
|
||||||
|
|
||||||
|
-- Row 3: equip/rename
|
||||||
|
btnY = h - 4
|
||||||
|
addButton(1, btnY, 8, 2, "EQUIP L", function()
|
||||||
|
-- Send eval to equip left
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipLeft()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(10, btnY, 8, 2, "EQUIP R", function()
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipRight()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(19, btnY, 7, 2, "RENAME", function()
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
term.setTextColor(colors.yellow)
|
||||||
|
print("Enter new name:")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
local name = read()
|
||||||
|
if name and #name > 0 then
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "rename",
|
||||||
|
name = name,
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end
|
||||||
|
draw()
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
viewMode = "overview"
|
viewMode = "overview"
|
||||||
end, colors.gray)
|
end, colors.gray)
|
||||||
@@ -363,9 +408,37 @@ local function drawManual()
|
|||||||
sendCommand(turtle.turtleID, "refuel")
|
sendCommand(turtle.turtleID, "refuel")
|
||||||
end, colors.lime)
|
end, colors.lime)
|
||||||
|
|
||||||
addButton(19, h - 3, 7, 2, "INFO", function()
|
addButton(19, h - 3, 7, 2, "SORT", function()
|
||||||
sendCommand(turtle.turtleID, "status")
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
end, colors.lightBlue)
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = [[
|
||||||
|
local moved = 0
|
||||||
|
for slot = 1, 16 do
|
||||||
|
local item = turtle.getItemDetail(slot)
|
||||||
|
if item then
|
||||||
|
for target = 1, slot - 1 do
|
||||||
|
local ti = turtle.getItemDetail(target)
|
||||||
|
if not ti then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
break
|
||||||
|
elseif ti.name == item.name and ti.count < 64 then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
if turtle.getItemCount(slot) == 0 then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
turtle.select(1)
|
||||||
|
return moved
|
||||||
|
]],
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
addButton(1, h, 12, 1, "< BACK", function()
|
addButton(1, h, 12, 1, "< BACK", function()
|
||||||
viewMode = "detail"
|
viewMode = "detail"
|
||||||
@@ -445,6 +518,24 @@ local function drawModes()
|
|||||||
sendStateCommand(turtle.turtleID, "moving")
|
sendStateCommand(turtle.turtleID, "moving")
|
||||||
end, colors.lightBlue)
|
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
|
-- Back button
|
||||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
viewMode = "detail"
|
viewMode = "detail"
|
||||||
@@ -540,10 +631,12 @@ parallel.waitForAny(
|
|||||||
end,
|
end,
|
||||||
function()
|
function()
|
||||||
-- Status receiver
|
-- Status receiver
|
||||||
|
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||||
|
-- both legacy (102) and target (4212) channels during migration.
|
||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||||
-- Update or add turtle
|
-- Update or add turtle
|
||||||
local found = false
|
local found = false
|
||||||
for i, t in ipairs(turtles) do
|
for i, t in ipairs(turtles) do
|
||||||
@@ -569,7 +662,7 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
|
|
||||||
draw()
|
draw()
|
||||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" then
|
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||||
-- State change confirmation or other response
|
-- State change confirmation or other response
|
||||||
if message.type == "state_changed" and message.turtleID then
|
if message.type == "state_changed" and message.turtleID then
|
||||||
for i, t in ipairs(turtles) do
|
for i, t in ipairs(turtles) do
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
# Node.js backend
|
# Stage 1: Fetch platform server package from git
|
||||||
|
FROM alpine:3.20 AS platform
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||||
|
ARG PLATFORM_BRANCH=master
|
||||||
|
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||||
|
&& rm -rf /src/server/node_modules /src/.git
|
||||||
|
|
||||||
|
# Stage 2: Node.js backend
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy platform server package from the git-clone stage
|
||||||
|
COPY --from=platform /src/server /app/platform-server/
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Rewrite file: dependency to use the local copy inside the container
|
||||||
|
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||||
|
&& rm -f package-lock.json
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy server code
|
# Copy all server code
|
||||||
COPY server.js ./
|
COPY . .
|
||||||
COPY database.js ./
|
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
EXPOSE 3001 3002
|
EXPOSE 3001 3002
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
# Development Dockerfile with hot reload
|
# Stage 1: Fetch platform server package from git
|
||||||
|
FROM alpine:3.20 AS platform
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||||
|
ARG PLATFORM_BRANCH=master
|
||||||
|
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||||
|
&& rm -rf /src/server/node_modules /src/.git
|
||||||
|
|
||||||
|
# Stage 2: Development with hot reload
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy platform server package from the git-clone stage
|
||||||
|
COPY --from=platform /src/server /app/platform-server/
|
||||||
|
|
||||||
# Install nodemon for hot reload
|
# Install nodemon for hot reload
|
||||||
RUN npm install -g nodemon
|
RUN npm install -g nodemon
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Rewrite file: dependency to use the local copy inside the container
|
||||||
|
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||||
|
&& rm -f package-lock.json
|
||||||
|
|
||||||
# Install all dependencies (including dev)
|
# Install all dependencies (including dev)
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
387
server/TaskDispatcher.js
Normal file
387
server/TaskDispatcher.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* TaskDispatcher — Automatic task queue dispatcher for RemoteTurtle
|
||||||
|
*
|
||||||
|
* Periodically polls the task_queue for pending tasks, matches them to
|
||||||
|
* available (idle + connected) turtles, maps task_type to state machine
|
||||||
|
* states, and drives the turtle state machine. Handles turtle disconnection
|
||||||
|
* mid-task (re-queues) and completion callbacks.
|
||||||
|
*
|
||||||
|
* Task type → State machine mapping:
|
||||||
|
* mine_area → mining (requires bounds in task_data)
|
||||||
|
* explore → exploring (requires target/area in task_data)
|
||||||
|
* gather → extracting (requires blockName in task_data)
|
||||||
|
* build → building (requires plan in task_data)
|
||||||
|
* transport → moving (requires target position)
|
||||||
|
* clear_area → mining (same as mine_area)
|
||||||
|
* scan → scanning (requires area/range)
|
||||||
|
* farm → farming (requires area)
|
||||||
|
* autocraft → autocrafting (requires recipe)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Map task_type values from the TaskPanel UI to turtle state machine state names + data mappers
|
||||||
|
const TASK_TYPE_MAP = {
|
||||||
|
mine_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||||
|
explore: { state: 'exploring', mapData: d => ({ target: d.target || d, ...d }) },
|
||||||
|
gather: { state: 'extracting', mapData: d => ({ blockName: d.blockName, count: d.count, ...d }) },
|
||||||
|
build: { state: 'building', mapData: d => ({ plan: d.plan, origin: d.origin, ...d }) },
|
||||||
|
transport: { state: 'moving', mapData: d => ({ target: d.target || d.destination || d, ...d }) },
|
||||||
|
clear_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||||
|
scan: { state: 'scanning', mapData: d => ({ area: d.area, ...d }) },
|
||||||
|
farm: { state: 'farming', mapData: d => ({ area: d.area, ...d }) },
|
||||||
|
autocraft: { state: 'autocrafting', mapData: d => ({ recipe: d.recipe, count: d.count, ...d }) },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TaskDispatcher {
|
||||||
|
/**
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {Map<number, import('./Turtle.js').Turtle>} opts.turtles - Live turtle map
|
||||||
|
* @param {Object} opts.db - Database module (server/database.js)
|
||||||
|
* @param {Function} opts.broadcastToClients - WebSocket broadcaster
|
||||||
|
* @param {number} [opts.pollInterval=5000] - ms between dispatch cycles
|
||||||
|
*/
|
||||||
|
constructor({ turtles, db, broadcastToClients, pollInterval = 5000 }) {
|
||||||
|
this._turtles = turtles;
|
||||||
|
this._db = db;
|
||||||
|
this._broadcast = broadcastToClients;
|
||||||
|
this._pollInterval = pollInterval;
|
||||||
|
this._timer = null;
|
||||||
|
this._enabled = true;
|
||||||
|
|
||||||
|
// Track which tasks are actively being executed by which turtles
|
||||||
|
// turtleId -> { taskId, taskType }
|
||||||
|
this._activeTasks = new Map();
|
||||||
|
|
||||||
|
// Reverse lookup: taskId -> turtleId
|
||||||
|
this._taskToTurtle = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the dispatch loop */
|
||||||
|
start() {
|
||||||
|
if (this._timer) return;
|
||||||
|
console.log(`🚀 TaskDispatcher started (poll every ${this._pollInterval}ms)`);
|
||||||
|
this._timer = setInterval(() => this._tick(), this._pollInterval);
|
||||||
|
// Run immediately on start
|
||||||
|
this._tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the dispatch loop */
|
||||||
|
stop() {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
console.log('🛑 TaskDispatcher stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable/disable automatic dispatching (tasks still tracked when disabled) */
|
||||||
|
set enabled(val) {
|
||||||
|
this._enabled = !!val;
|
||||||
|
console.log(`TaskDispatcher: auto-dispatch ${this._enabled ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return this._enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get dispatcher status for the API */
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
enabled: this._enabled,
|
||||||
|
activeTasks: Array.from(this._activeTasks.entries()).map(([turtleId, info]) => ({
|
||||||
|
turtleId,
|
||||||
|
...info,
|
||||||
|
})),
|
||||||
|
pollInterval: this._pollInterval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Internal ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dispatch cycle:
|
||||||
|
* 1. Reconcile active tasks (detect turtle disconnects, state completions)
|
||||||
|
* 2. If enabled, find pending tasks and assign to idle turtles
|
||||||
|
*/
|
||||||
|
_tick() {
|
||||||
|
try {
|
||||||
|
this._reconcile();
|
||||||
|
if (this._enabled) {
|
||||||
|
this._dispatch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TaskDispatcher] tick error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile: check if turtles executing tasks have finished or disconnected.
|
||||||
|
*/
|
||||||
|
_reconcile() {
|
||||||
|
for (const [turtleId, info] of this._activeTasks) {
|
||||||
|
const turtle = this._turtles.get(turtleId);
|
||||||
|
|
||||||
|
// Turtle removed from server
|
||||||
|
if (!turtle) {
|
||||||
|
this._handleTaskFailure(info.taskId, turtleId, 'Turtle no longer exists');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turtle disconnected mid-task
|
||||||
|
if (!turtle.connected) {
|
||||||
|
this._handleTaskFailure(info.taskId, turtleId, 'Turtle disconnected');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turtle transitioned to idle → task completed
|
||||||
|
if (turtle.stateName === 'idle') {
|
||||||
|
this._handleTaskCompletion(info.taskId, turtleId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turtle errored out
|
||||||
|
if (turtle._error) {
|
||||||
|
this._handleTaskFailure(info.taskId, turtleId, turtle._error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch: find pending (unassigned or assigned-but-not-started) tasks,
|
||||||
|
* match to available turtles, and start them.
|
||||||
|
*/
|
||||||
|
_dispatch() {
|
||||||
|
// Get all pending tasks (sorted by priority DESC, created_at ASC via DB)
|
||||||
|
let pendingTasks;
|
||||||
|
try {
|
||||||
|
pendingTasks = this._db.getAllTasks('pending');
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingTasks || pendingTasks.length === 0) return;
|
||||||
|
|
||||||
|
// Find idle, connected turtles not already executing a task
|
||||||
|
const availableTurtles = this._getAvailableTurtles();
|
||||||
|
if (availableTurtles.length === 0) return;
|
||||||
|
|
||||||
|
for (const task of pendingTasks) {
|
||||||
|
if (availableTurtles.length === 0) break;
|
||||||
|
|
||||||
|
const mapping = TASK_TYPE_MAP[task.task_type];
|
||||||
|
if (!mapping) {
|
||||||
|
console.warn(`[TaskDispatcher] Unknown task type: ${task.task_type} (task #${task.id})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If task has a specific turtle assignment, respect it
|
||||||
|
let turtle = null;
|
||||||
|
if (task.assigned_turtle_id) {
|
||||||
|
turtle = this._turtles.get(task.assigned_turtle_id);
|
||||||
|
if (!turtle || !turtle.connected || turtle.stateName !== 'idle') {
|
||||||
|
// Assigned turtle not available — skip this task for now
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Remove from available pool
|
||||||
|
const idx = availableTurtles.indexOf(turtle);
|
||||||
|
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
// Pick the best available turtle (closest to target if we have coords, else first)
|
||||||
|
turtle = this._pickBestTurtle(availableTurtles, task.task_data);
|
||||||
|
const idx = availableTurtles.indexOf(turtle);
|
||||||
|
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!turtle) continue;
|
||||||
|
|
||||||
|
// Dispatch!
|
||||||
|
this._startTask(task, turtle, mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a task on a turtle.
|
||||||
|
*/
|
||||||
|
_startTask(task, turtle, mapping) {
|
||||||
|
const taskId = task.id;
|
||||||
|
const turtleId = turtle.id;
|
||||||
|
|
||||||
|
console.log(`[TaskDispatcher] Assigning task #${taskId} (${task.task_type}) → Turtle #${turtleId}`);
|
||||||
|
|
||||||
|
// Map task_data to state data
|
||||||
|
const stateData = mapping.mapData(task.task_data || {});
|
||||||
|
|
||||||
|
// Update DB: assign + set in_progress
|
||||||
|
try {
|
||||||
|
this._db.assignTask(taskId, turtleId);
|
||||||
|
this._db.updateTaskStatus(taskId, 'in_progress');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[TaskDispatcher] DB error assigning task #${taskId}:`, e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track
|
||||||
|
this._activeTasks.set(turtleId, { taskId, taskType: task.task_type });
|
||||||
|
this._taskToTurtle.set(taskId, turtleId);
|
||||||
|
|
||||||
|
// Broadcast updates
|
||||||
|
this._broadcast({ type: 'task_assigned', taskId, turtleId });
|
||||||
|
this._broadcast({ type: 'task_updated', taskId, status: 'in_progress' });
|
||||||
|
|
||||||
|
// Set the turtle's state machine
|
||||||
|
turtle.setState(mapping.state, stateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle task completion (turtle went back to idle after executing).
|
||||||
|
*/
|
||||||
|
_handleTaskCompletion(taskId, turtleId) {
|
||||||
|
console.log(`[TaskDispatcher] Task #${taskId} completed by Turtle #${turtleId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._db.completeTask(taskId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[TaskDispatcher] DB error completing task #${taskId}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeTasks.delete(turtleId);
|
||||||
|
this._taskToTurtle.delete(taskId);
|
||||||
|
|
||||||
|
this._broadcast({ type: 'task_completed', taskId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle task failure (turtle disconnected, errored, etc.).
|
||||||
|
* Re-queues the task as pending so another turtle can pick it up.
|
||||||
|
*/
|
||||||
|
_handleTaskFailure(taskId, turtleId, reason) {
|
||||||
|
console.warn(`[TaskDispatcher] Task #${taskId} failed on Turtle #${turtleId}: ${reason}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-queue: set back to pending, clear assignment
|
||||||
|
this._db.updateTaskStatus(taskId, 'pending', reason);
|
||||||
|
this._db.assignTask(taskId, null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[TaskDispatcher] DB error re-queuing task #${taskId}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._activeTasks.delete(turtleId);
|
||||||
|
this._taskToTurtle.delete(taskId);
|
||||||
|
|
||||||
|
this._broadcast({ type: 'task_updated', taskId, status: 'pending' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually cancel a running task (called via API).
|
||||||
|
*/
|
||||||
|
cancelTask(taskId) {
|
||||||
|
const turtleId = this._taskToTurtle.get(taskId);
|
||||||
|
if (turtleId !== undefined) {
|
||||||
|
const turtle = this._turtles.get(turtleId);
|
||||||
|
if (turtle) {
|
||||||
|
turtle.setState('idle');
|
||||||
|
}
|
||||||
|
this._activeTasks.delete(turtleId);
|
||||||
|
this._taskToTurtle.delete(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._db.updateTaskStatus(taskId, 'cancelled');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[TaskDispatcher] DB error cancelling task #${taskId}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._broadcast({ type: 'task_updated', taskId, status: 'cancelled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific task is currently being executed.
|
||||||
|
*/
|
||||||
|
isTaskActive(taskId) {
|
||||||
|
return this._taskToTurtle.has(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helpers ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connected, idle turtles not currently executing a dispatched task.
|
||||||
|
*/
|
||||||
|
_getAvailableTurtles() {
|
||||||
|
const available = [];
|
||||||
|
for (const [id, turtle] of this._turtles) {
|
||||||
|
if (
|
||||||
|
turtle.connected &&
|
||||||
|
turtle.stateName === 'idle' &&
|
||||||
|
!this._activeTasks.has(id)
|
||||||
|
) {
|
||||||
|
available.push(turtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best turtle for a task.
|
||||||
|
* If task_data has coordinates, pick the closest turtle with a known position.
|
||||||
|
* Otherwise, pick the turtle with the most fuel.
|
||||||
|
*/
|
||||||
|
_pickBestTurtle(candidates, taskData) {
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
|
||||||
|
// Try to extract a target position from task data
|
||||||
|
const target = this._extractTarget(taskData);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
// Sort by Manhattan distance to target
|
||||||
|
let bestTurtle = candidates[0];
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
for (const t of candidates) {
|
||||||
|
if (t.position) {
|
||||||
|
const dist = Math.abs(t.position.x - target.x)
|
||||||
|
+ Math.abs(t.position.y - target.y)
|
||||||
|
+ Math.abs(t.position.z - target.z);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestTurtle = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestTurtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No target — pick turtle with highest fuel
|
||||||
|
return candidates.reduce((best, t) => (t._fuel > best._fuel ? t : best), candidates[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract a target {x,y,z} from task_data.
|
||||||
|
*/
|
||||||
|
_extractTarget(data) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Direct target
|
||||||
|
if (data.target && typeof data.target.x === 'number') return data.target;
|
||||||
|
if (data.destination && typeof data.destination.x === 'number') return data.destination;
|
||||||
|
|
||||||
|
// Bounds — use center
|
||||||
|
if (data.bounds) {
|
||||||
|
const b = data.bounds;
|
||||||
|
if (typeof b.minX === 'number' && typeof b.maxX === 'number') {
|
||||||
|
return {
|
||||||
|
x: Math.floor((b.minX + b.maxX) / 2),
|
||||||
|
y: Math.floor((b.minY + b.maxY) / 2),
|
||||||
|
z: Math.floor((b.minZ + b.maxZ) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coordinate pair (from TaskPanel)
|
||||||
|
if (typeof data.startX === 'number' && typeof data.startZ === 'number') {
|
||||||
|
return { x: data.startX, y: data.startY || 64, z: data.startZ };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
769
server/Turtle.js
769
server/Turtle.js
@@ -3,9 +3,13 @@
|
|||||||
*
|
*
|
||||||
* Each connected turtle gets a Turtle instance that manages:
|
* Each connected turtle gets a Turtle instance that manages:
|
||||||
* - State machine (idle, mining, exploring, etc.)
|
* - State machine (idle, mining, exploring, etc.)
|
||||||
* - UUID-based command-response correlation
|
* - UUID-based command-response correlation with promise chaining
|
||||||
* - Position/facing/inventory tracking
|
* - Position/facing/inventory tracking
|
||||||
* - Event emission for real-time UI updates
|
* - Event emission for real-time UI updates
|
||||||
|
* - Turn-to-direction, equip, rename, inventory transfer
|
||||||
|
* - External inventory interaction (connectToInventory)
|
||||||
|
* - Block deletion on movement
|
||||||
|
* - State recovery on reconnection
|
||||||
*
|
*
|
||||||
* Inspired by runi95/turtle-control-panel Turtle class.
|
* Inspired by runi95/turtle-control-panel Turtle class.
|
||||||
*/
|
*/
|
||||||
@@ -26,6 +30,15 @@ import {
|
|||||||
AutocraftState,
|
AutocraftState,
|
||||||
} from './states/index.js';
|
} from './states/index.js';
|
||||||
|
|
||||||
|
// Random name list for unnamed turtles (inspired by reference)
|
||||||
|
const TURTLE_NAMES = [
|
||||||
|
'Atlas', 'Bolt', 'Chip', 'Dash', 'Echo', 'Flint', 'Gizmo', 'Hex',
|
||||||
|
'Iron', 'Jazz', 'Kit', 'Lumen', 'Miko', 'Nova', 'Onyx', 'Pixel',
|
||||||
|
'Quartz', 'Rust', 'Spark', 'Terra', 'Volt', 'Widget', 'Xenon', 'Zinc',
|
||||||
|
'Amber', 'Blaze', 'Cobalt', 'Drake', 'Ember', 'Forge', 'Granite', 'Haze',
|
||||||
|
'Ingot', 'Jasper', 'Kindle', 'Lapis', 'Marble', 'Nickel', 'Opal', 'Prism',
|
||||||
|
];
|
||||||
|
|
||||||
const STATE_MAP = {
|
const STATE_MAP = {
|
||||||
idle: IdleState,
|
idle: IdleState,
|
||||||
moving: MovingState,
|
moving: MovingState,
|
||||||
@@ -50,6 +63,9 @@ const STATE_MAP = {
|
|||||||
// Timeout for exec() commands (ms)
|
// Timeout for exec() commands (ms)
|
||||||
const EXEC_TIMEOUT = 30000;
|
const EXEC_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Direction enum matching reference: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X)
|
||||||
|
const DIRECTION = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 };
|
||||||
|
|
||||||
export class Turtle extends EventEmitter {
|
export class Turtle extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* @param {number} id - The ComputerCraft turtle ID
|
* @param {number} id - The ComputerCraft turtle ID
|
||||||
@@ -77,17 +93,23 @@ export class Turtle extends EventEmitter {
|
|||||||
this._state = null;
|
this._state = null;
|
||||||
this._stateRunner = null; // The running async generator loop
|
this._stateRunner = null; // The running async generator loop
|
||||||
|
|
||||||
// Command correlation
|
// Command correlation with promise chaining (prevents concurrent commands racing)
|
||||||
this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout }
|
this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout }
|
||||||
this._messageIndex = 0;
|
this._messageIndex = 0;
|
||||||
|
this._lastPromise = Promise.resolve(); // Promise chain for sequential exec
|
||||||
|
|
||||||
|
// Fuel efficiency tracking
|
||||||
|
this._stepsSinceLastRefuel = 0;
|
||||||
|
this._totalSteps = 0;
|
||||||
|
this._totalFuelUsed = 0;
|
||||||
|
|
||||||
// Connection tracking
|
// Connection tracking
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.lastUpdate = Date.now();
|
this.lastUpdate = Date.now();
|
||||||
this.lastStatusBroadcast = 0;
|
this.lastStatusBroadcast = 0;
|
||||||
|
|
||||||
// Legacy command queue (for pocket computer compatibility)
|
// Command queue for eval commands sent to webbridge
|
||||||
this.pendingLegacyCommands = [];
|
this.pendingCommands = [];
|
||||||
|
|
||||||
// Start in idle state
|
// Start in idle state
|
||||||
this.setState('idle');
|
this.setState('idle');
|
||||||
@@ -159,6 +181,7 @@ export class Turtle extends EventEmitter {
|
|||||||
|
|
||||||
set selectedSlot(value) {
|
set selectedSlot(value) {
|
||||||
this._selectedSlot = value;
|
this._selectedSlot = value;
|
||||||
|
this._emitUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
get fuelLimit() {
|
get fuelLimit() {
|
||||||
@@ -169,6 +192,15 @@ export class Turtle extends EventEmitter {
|
|||||||
this._fuelLimit = value;
|
this._fuelLimit = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get label() {
|
||||||
|
return this._label;
|
||||||
|
}
|
||||||
|
|
||||||
|
set label(value) {
|
||||||
|
this._label = value;
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
get error() {
|
get error() {
|
||||||
return this._error;
|
return this._error;
|
||||||
}
|
}
|
||||||
@@ -218,16 +250,52 @@ export class Turtle extends EventEmitter {
|
|||||||
this._state = new StateClass(this, data);
|
this._state = new StateClass(this, data);
|
||||||
console.log(`[Turtle ${this.id}] State: ${this._state.name} - ${this._state.description}`);
|
console.log(`[Turtle ${this.id}] State: ${this._state.name} - ${this._state.description}`);
|
||||||
|
|
||||||
|
// Persist state for recovery (skip idle to avoid DB noise)
|
||||||
|
if (stateName !== 'idle' && this.server?.db) {
|
||||||
|
try {
|
||||||
|
this.server.db.saveTurtleState(this.id, stateName, data);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors in state persistence
|
||||||
|
}
|
||||||
|
} else if (stateName === 'idle' && this.server?.db) {
|
||||||
|
try {
|
||||||
|
this.server.db.deleteTurtleState(this.id);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Run the state's act() generator loop
|
// Run the state's act() generator loop
|
||||||
this._runStateLoop();
|
this._runStateLoop();
|
||||||
this._emitUpdate();
|
this._emitUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover state from database on reconnection
|
||||||
|
*/
|
||||||
|
recoverState() {
|
||||||
|
if (!this.server?.db) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = this.server.db.getTurtleState(this.id);
|
||||||
|
if (saved && saved.stateName && saved.stateName !== 'idle') {
|
||||||
|
const StateClass = STATE_MAP[saved.stateName];
|
||||||
|
if (StateClass) {
|
||||||
|
console.log(`[Turtle ${this.id}] Recovering state: ${saved.stateName}`);
|
||||||
|
this.setState(saved.stateName, saved.stateData || {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Turtle ${this.id}] Error recovering state:`, e.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the current state's act() generator in a loop
|
* Run the current state's act() generator in a loop
|
||||||
*/
|
*/
|
||||||
async _runStateLoop() {
|
async _runStateLoop(consecutiveErrors = 0) {
|
||||||
const state = this._state;
|
const state = this._state;
|
||||||
|
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generator = state.act();
|
const generator = state.act();
|
||||||
@@ -237,56 +305,91 @@ export class Turtle extends EventEmitter {
|
|||||||
if (this._state !== state) {
|
if (this._state !== state) {
|
||||||
return; // State changed, stop this generator
|
return; // State changed, stop this generator
|
||||||
}
|
}
|
||||||
|
// Reset error counter on successful iteration
|
||||||
|
consecutiveErrors = 0;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this._state === state) {
|
if (this._state !== state) return;
|
||||||
|
|
||||||
|
consecutiveErrors++;
|
||||||
|
const isTimeout = error.message?.includes('timed out');
|
||||||
|
|
||||||
|
if (isTimeout && consecutiveErrors < MAX_CONSECUTIVE_ERRORS) {
|
||||||
|
console.warn(`[Turtle ${this.id}] Timeout in ${state.name} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}), retrying...`);
|
||||||
|
// Wait a bit then restart the state loop, passing the error count
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
if (this._state === state) {
|
||||||
|
this._runStateLoop(consecutiveErrors);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
|
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
|
||||||
this.setState('idle');
|
// Don't transition idle→idle (causes infinite loop)
|
||||||
|
if (state.name !== 'idle') {
|
||||||
|
this.setState('idle');
|
||||||
|
} else {
|
||||||
|
// Already idle and erroring — just wait and retry the idle loop
|
||||||
|
console.log(`[Turtle ${this.id}] Idle loop paused, will retry in 60s`);
|
||||||
|
await new Promise(r => setTimeout(r, 60000));
|
||||||
|
if (this._state === state) {
|
||||||
|
this._runStateLoop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Command Execution (UUID-based) ==========
|
// ========== Command Execution (UUID-based with Promise Chaining) ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute Lua code on the turtle and return the result.
|
* Execute Lua code on the turtle and return the result.
|
||||||
* Uses UUID-based command-response correlation.
|
* Uses UUID-based command-response correlation.
|
||||||
|
* Commands are chained sequentially via lastPromise to prevent race conditions.
|
||||||
*
|
*
|
||||||
* @param {string} luaCode - The Lua code to execute
|
* @param {string} luaCode - The Lua code to execute
|
||||||
* @param {number} timeout - Timeout in ms (default: 30s)
|
* @param {number} timeout - Timeout in ms (default: 30s)
|
||||||
* @returns {Promise<any>} The result of the Lua execution
|
* @returns {Promise<any>} The result of the Lua execution
|
||||||
*/
|
*/
|
||||||
exec(luaCode, timeout = EXEC_TIMEOUT) {
|
exec(luaCode, timeout = EXEC_TIMEOUT) {
|
||||||
return new Promise((resolve, reject) => {
|
// Chain through lastPromise to ensure sequential execution
|
||||||
const uuid = randomUUID();
|
const execPromise = new Promise((resolve, reject) => {
|
||||||
const messageIndex = this._messageIndex++;
|
this._lastPromise.catch(() => {}).finally(() => {
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const messageIndex = this._messageIndex++;
|
||||||
|
|
||||||
// Set up timeout
|
// Set up timeout
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
this._pendingCommands.delete(uuid);
|
const pending = this._pendingCommands.get(uuid);
|
||||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
if (pending) {
|
||||||
}, timeout);
|
console.warn(`[Turtle ${this.id}] ⏰ Command timed out uuid:${uuid.substring(0, 8)} (pending: ${this._pendingCommands.size}, code: ${luaCode.substring(0, 50)})`);
|
||||||
|
}
|
||||||
|
this._pendingCommands.delete(uuid);
|
||||||
|
reject(new Error(`Command timed out after ${timeout}ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
// Store the pending command
|
// Store the pending command
|
||||||
this._pendingCommands.set(uuid, {
|
this._pendingCommands.set(uuid, {
|
||||||
resolve: (result) => {
|
resolve: (result) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._pendingCommands.delete(uuid);
|
this._pendingCommands.delete(uuid);
|
||||||
resolve(result);
|
resolve(result);
|
||||||
},
|
},
|
||||||
reject: (error) => {
|
reject: (error) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._pendingCommands.delete(uuid);
|
this._pendingCommands.delete(uuid);
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
timeout: timer,
|
timeout: timer,
|
||||||
luaCode,
|
luaCode,
|
||||||
sentAt: Date.now(),
|
sentAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the command to the turtle (via webbridge modem relay)
|
||||||
|
this._sendExecCommand(uuid, messageIndex, luaCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send the command to the turtle (via webbridge modem relay)
|
|
||||||
this._sendExecCommand(uuid, messageIndex, luaCode);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._lastPromise = execPromise;
|
||||||
|
return execPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,6 +408,575 @@ export class Turtle extends EventEmitter {
|
|||||||
this.emit('sendCommand', command);
|
this.emit('sendCommand', command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Turtle API Methods ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the turtle to face a specific cardinal direction.
|
||||||
|
* Calculates the optimal turn direction (left or right).
|
||||||
|
* @param {number} direction - 0=North, 1=East, 2=South, 3=West
|
||||||
|
*/
|
||||||
|
async turnToDirection(direction) {
|
||||||
|
if (this._facing === direction) return;
|
||||||
|
|
||||||
|
const turn = (direction - this._facing + 4) % 4;
|
||||||
|
if (turn === 1) {
|
||||||
|
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${direction}`);
|
||||||
|
this._facing = direction;
|
||||||
|
this._emitUpdate();
|
||||||
|
} else if (turn === 2) {
|
||||||
|
await this.exec(`turtle.turnLeft(); turtle.turnLeft(); _G._turtleFacing = ${direction}`);
|
||||||
|
this._facing = direction;
|
||||||
|
this._emitUpdate();
|
||||||
|
} else if (turn === 3) {
|
||||||
|
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${direction}`);
|
||||||
|
this._facing = direction;
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the turtle left
|
||||||
|
*/
|
||||||
|
async turnLeft() {
|
||||||
|
const result = await this.exec('return turtle.turnLeft()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this._facing = (this._facing + 3) % 4;
|
||||||
|
// Sync facing on turtle side
|
||||||
|
this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {});
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the turtle right
|
||||||
|
*/
|
||||||
|
async turnRight() {
|
||||||
|
const result = await this.exec('return turtle.turnRight()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this._facing = (this._facing + 1) % 4;
|
||||||
|
// Sync facing on turtle side
|
||||||
|
this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {});
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the turtle forward. Updates position and deletes block at destination.
|
||||||
|
*/
|
||||||
|
async forward() {
|
||||||
|
const result = await this.exec('return turtle.forward()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this.updatePositionForward();
|
||||||
|
this._deleteBlockAtPosition(this._position);
|
||||||
|
this._stepsSinceLastRefuel++;
|
||||||
|
this._totalSteps++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the turtle backward.
|
||||||
|
*/
|
||||||
|
async back() {
|
||||||
|
const result = await this.exec('return turtle.back()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
// Update position for backward movement
|
||||||
|
if (this._position) {
|
||||||
|
const pos = { ...this._position };
|
||||||
|
if (this._facing === 0) pos.z += 1;
|
||||||
|
else if (this._facing === 1) pos.x -= 1;
|
||||||
|
else if (this._facing === 2) pos.z -= 1;
|
||||||
|
else if (this._facing === 3) pos.x += 1;
|
||||||
|
this.position = pos;
|
||||||
|
this._deleteBlockAtPosition(pos);
|
||||||
|
}
|
||||||
|
this._stepsSinceLastRefuel++;
|
||||||
|
this._totalSteps++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the turtle up. Updates position and deletes block at destination.
|
||||||
|
*/
|
||||||
|
async up() {
|
||||||
|
const result = await this.exec('return turtle.up()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this.updatePositionUp();
|
||||||
|
this._deleteBlockAtPosition(this._position);
|
||||||
|
this._stepsSinceLastRefuel++;
|
||||||
|
this._totalSteps++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the turtle down. Updates position and deletes block at destination.
|
||||||
|
*/
|
||||||
|
async down() {
|
||||||
|
const result = await this.exec('return turtle.down()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this.updatePositionDown();
|
||||||
|
this._deleteBlockAtPosition(this._position);
|
||||||
|
this._stepsSinceLastRefuel++;
|
||||||
|
this._totalSteps++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dig the block in front
|
||||||
|
*/
|
||||||
|
async dig() {
|
||||||
|
const result = await this.exec('return turtle.dig()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
const pos = this.getBlockPositionInDirection('forward');
|
||||||
|
if (pos) this._deleteBlockAtPosition(pos);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dig the block above
|
||||||
|
*/
|
||||||
|
async digUp() {
|
||||||
|
const result = await this.exec('return turtle.digUp()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
const pos = this.getBlockPositionInDirection('up');
|
||||||
|
if (pos) this._deleteBlockAtPosition(pos);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dig the block below
|
||||||
|
*/
|
||||||
|
async digDown() {
|
||||||
|
const result = await this.exec('return turtle.digDown()');
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
const pos = this.getBlockPositionInDirection('down');
|
||||||
|
if (pos) this._deleteBlockAtPosition(pos);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select an inventory slot
|
||||||
|
*/
|
||||||
|
async select(slot = 1) {
|
||||||
|
const result = await this.exec(`return turtle.select(${slot})`);
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
this._selectedSlot = slot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer items from current slot to another slot
|
||||||
|
*/
|
||||||
|
async transferTo(slot, count) {
|
||||||
|
const cmd = count != null
|
||||||
|
? `return turtle.transferTo(${slot}, ${count})`
|
||||||
|
: `return turtle.transferTo(${slot})`;
|
||||||
|
return this.exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer items between inventory slots (select from, transferTo target, reselect)
|
||||||
|
*/
|
||||||
|
async inventoryTransfer(fromSlot, toSlot, count) {
|
||||||
|
const currentSlot = this._selectedSlot;
|
||||||
|
await this.select(fromSlot);
|
||||||
|
await this.transferTo(toSlot, count);
|
||||||
|
await this.select(currentSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop items from current slot (front direction)
|
||||||
|
*/
|
||||||
|
async drop(count) {
|
||||||
|
const cmd = count != null ? `return turtle.drop(${count})` : 'return turtle.drop()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
// Auto-refresh adjacent inventory if present
|
||||||
|
if (this._peripherals?.front?.types?.includes('inventory')) {
|
||||||
|
try { await this.connectToInventory('front'); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop items upward
|
||||||
|
*/
|
||||||
|
async dropUp(count) {
|
||||||
|
const cmd = count != null ? `return turtle.dropUp(${count})` : 'return turtle.dropUp()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
if (this._peripherals?.top?.types?.includes('inventory')) {
|
||||||
|
try { await this.connectToInventory('top'); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop items downward
|
||||||
|
*/
|
||||||
|
async dropDown(count) {
|
||||||
|
const cmd = count != null ? `return turtle.dropDown(${count})` : 'return turtle.dropDown()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
if (this._peripherals?.bottom?.types?.includes('inventory')) {
|
||||||
|
try { await this.connectToInventory('bottom'); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suck items from front
|
||||||
|
*/
|
||||||
|
async suck(count) {
|
||||||
|
const cmd = count != null ? `return turtle.suck(${count})` : 'return turtle.suck()';
|
||||||
|
return this.exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suck items from above
|
||||||
|
*/
|
||||||
|
async suckUp(count) {
|
||||||
|
const cmd = count != null ? `return turtle.suckUp(${count})` : 'return turtle.suckUp()';
|
||||||
|
return this.exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suck items from below
|
||||||
|
*/
|
||||||
|
async suckDown(count) {
|
||||||
|
const cmd = count != null ? `return turtle.suckDown(${count})` : 'return turtle.suckDown()';
|
||||||
|
return this.exec(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place block in front
|
||||||
|
*/
|
||||||
|
async place(text) {
|
||||||
|
const cmd = text ? `return turtle.place("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.place()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
await this.inspect(); // Auto-inspect to update world map
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place block above
|
||||||
|
*/
|
||||||
|
async placeUp(text) {
|
||||||
|
const cmd = text ? `return turtle.placeUp("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeUp()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
await this.inspectUp();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place block below
|
||||||
|
*/
|
||||||
|
async placeDown(text) {
|
||||||
|
const cmd = text ? `return turtle.placeDown("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeDown()';
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||||
|
await this.inspectDown();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect block in front and update world map
|
||||||
|
*/
|
||||||
|
async inspect() {
|
||||||
|
const result = await this.exec('local h, d = turtle.inspect(); if h then return d else return nil end');
|
||||||
|
if (result && result.name) {
|
||||||
|
const pos = this.getBlockPositionInDirection('forward');
|
||||||
|
if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect block above
|
||||||
|
*/
|
||||||
|
async inspectUp() {
|
||||||
|
const result = await this.exec('local h, d = turtle.inspectUp(); if h then return d else return nil end');
|
||||||
|
if (result && result.name) {
|
||||||
|
const pos = this.getBlockPositionInDirection('up');
|
||||||
|
if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect block below
|
||||||
|
*/
|
||||||
|
async inspectDown() {
|
||||||
|
const result = await this.exec('local h, d = turtle.inspectDown(); if h then return d else return nil end');
|
||||||
|
if (result && result.name) {
|
||||||
|
const pos = this.getBlockPositionInDirection('down');
|
||||||
|
if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batched 3-direction inspect (up/down/forward) in a single eval call.
|
||||||
|
* More efficient than 3 separate calls.
|
||||||
|
*/
|
||||||
|
async explore() {
|
||||||
|
const result = 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 (result && typeof result === 'object') {
|
||||||
|
this.processScanResults(result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuel the turtle
|
||||||
|
*/
|
||||||
|
async refuel(count) {
|
||||||
|
const cmd = count != null ? `return turtle.refuel(${count})` : 'return turtle.refuel()';
|
||||||
|
const fuelBefore = this._fuel;
|
||||||
|
const result = await this.exec(cmd);
|
||||||
|
// Update fuel level
|
||||||
|
const fuelLevel = await this.exec('return turtle.getFuelLevel()');
|
||||||
|
if (fuelLevel != null) {
|
||||||
|
this._totalFuelUsed += (fuelLevel - fuelBefore);
|
||||||
|
this._fuel = fuelLevel;
|
||||||
|
this._stepsSinceLastRefuel = 0;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Craft using workbench peripheral
|
||||||
|
*/
|
||||||
|
async craft() {
|
||||||
|
const result = await this.exec(`
|
||||||
|
local hasWorkbench = peripheral.find("workbench") ~= nil
|
||||||
|
if not hasWorkbench then return false, "No workbench" end
|
||||||
|
return peripheral.find("workbench").craft()
|
||||||
|
`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equip an item on the left side of the turtle
|
||||||
|
*/
|
||||||
|
async equipLeft() {
|
||||||
|
return this.exec('return turtle.equipLeft()');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equip an item on the right side of the turtle
|
||||||
|
*/
|
||||||
|
async equipRight() {
|
||||||
|
return this.exec('return turtle.equipRight()');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the turtle (set computer label)
|
||||||
|
*/
|
||||||
|
async rename(name) {
|
||||||
|
// Escape quotes and backslashes for safe Lua string interpolation
|
||||||
|
const safeName = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
await this.exec(`os.setComputerLabel("${safeName}")`);
|
||||||
|
this._label = name;
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-assign a random name if the turtle has no label
|
||||||
|
*/
|
||||||
|
autoName() {
|
||||||
|
if (!this._label) {
|
||||||
|
const name = TURTLE_NAMES[Math.floor(Math.random() * TURTLE_NAMES.length)];
|
||||||
|
this._label = name;
|
||||||
|
// Send rename command to turtle when connected
|
||||||
|
if (this.connected) {
|
||||||
|
this.rename(name).catch(() => {});
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return this._label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS locate
|
||||||
|
*/
|
||||||
|
async gpsLocate() {
|
||||||
|
const result = await this.exec('local x,y,z = gps.locate(5); if x then return {x=x, y=y, z=z} else return nil end');
|
||||||
|
if (result && result.x != null) {
|
||||||
|
this.position = { x: Math.floor(result.x), y: Math.floor(result.y), z: Math.floor(result.z) };
|
||||||
|
}
|
||||||
|
return this._position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write content to a file on the turtle's filesystem
|
||||||
|
* @param {string} path - The file path on the turtle (e.g., 'log.txt', 'scripts/miner.lua')
|
||||||
|
* @param {string} content - The content to write
|
||||||
|
* @param {boolean} append - Whether to append instead of overwrite
|
||||||
|
*/
|
||||||
|
async writeToFile(path, content, append = false) {
|
||||||
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
// Encode content as base64-like hex to avoid Lua string escaping issues
|
||||||
|
const mode = append ? 'a' : 'w';
|
||||||
|
// Split content into chunks to avoid oversized eval commands
|
||||||
|
const chunkSize = 4000;
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < content.length; i += chunkSize) {
|
||||||
|
chunks.push(content.substring(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length <= 1) {
|
||||||
|
// Single write for small files
|
||||||
|
const safeContent = (chunks[0] || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
|
||||||
|
return this.exec(`
|
||||||
|
local f = fs.open("${safePath}", "${mode}")
|
||||||
|
if f then
|
||||||
|
f.write("${safeContent}")
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false, "Cannot open file"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-chunk write for large files
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const safeChunk = chunks[i].replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
|
||||||
|
const writeMode = i === 0 ? mode : 'a';
|
||||||
|
const result = await this.exec(`
|
||||||
|
local f = fs.open("${safePath}", "${writeMode}")
|
||||||
|
if f then
|
||||||
|
f.write("${safeChunk}")
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false, "Cannot open file"
|
||||||
|
`);
|
||||||
|
if (result !== true) return result;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh detailed inventory state for all 16 slots.
|
||||||
|
* Gets name, count, damage, and maxCount for each slot.
|
||||||
|
*/
|
||||||
|
async refreshInventoryState() {
|
||||||
|
const result = await this.exec(`
|
||||||
|
local inv = {}
|
||||||
|
for slot = 1, 16 do
|
||||||
|
local item = turtle.getItemDetail(slot)
|
||||||
|
if item then
|
||||||
|
inv[tostring(slot)] = {
|
||||||
|
name = item.name,
|
||||||
|
count = item.count,
|
||||||
|
damage = item.damage or 0,
|
||||||
|
maxCount = turtle.getItemSpace(slot) + item.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return inv
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
this._inventory = result;
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to an adjacent inventory peripheral and read its contents
|
||||||
|
*/
|
||||||
|
async connectToInventory(side) {
|
||||||
|
const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const result = await this.exec(`
|
||||||
|
local size = peripheral.call("${safeSide}", "size")
|
||||||
|
local content = peripheral.call("${safeSide}", "list")
|
||||||
|
if next(content) == nil then content = nil end
|
||||||
|
return {size = size, content = content}
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this._peripherals = {
|
||||||
|
...this._peripherals,
|
||||||
|
[side]: {
|
||||||
|
...this._peripherals[side],
|
||||||
|
data: {
|
||||||
|
size: result.size,
|
||||||
|
content: result.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all attached peripherals (scan what's connected)
|
||||||
|
*/
|
||||||
|
async updateAllAttachedPeripherals() {
|
||||||
|
const result = await this.exec(`
|
||||||
|
local peripherals = {}
|
||||||
|
local names = peripheral.getNames()
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
local types = {peripheral.getType(name)}
|
||||||
|
peripherals[name] = {types = types}
|
||||||
|
end
|
||||||
|
return peripherals
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
this._peripherals = result;
|
||||||
|
this._emitUpdate();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a peripheral method by side
|
||||||
|
*/
|
||||||
|
async usePeripheralWithSide(side, method, ...args) {
|
||||||
|
const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const safeMethod = method.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const argsStr = args.map(a => {
|
||||||
|
if (a === null) return 'nil';
|
||||||
|
if (typeof a === 'string') return `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||||
|
return String(a);
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
return this.exec(`return peripheral.call("${safeSide}", "${safeMethod}"${argsStr ? ', ' + argsStr : ''})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for a duration (on the turtle side)
|
||||||
|
*/
|
||||||
|
async sleep(seconds) {
|
||||||
|
return this.exec(`sleep(${seconds})`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Real-time Event Handling ==========
|
// ========== Real-time Event Handling ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,6 +1086,29 @@ export class Turtle extends EventEmitter {
|
|||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a block from both in-memory world map and database (turtle moved into it = now air)
|
||||||
|
*/
|
||||||
|
_deleteBlockAtPosition(pos) {
|
||||||
|
if (!pos) return;
|
||||||
|
const key = `${pos.x},${pos.y},${pos.z}`;
|
||||||
|
if (this.server?.worldBlocks) {
|
||||||
|
this.server.worldBlocks.delete(key);
|
||||||
|
}
|
||||||
|
if (this.server?.db) {
|
||||||
|
try {
|
||||||
|
this.server.db.deleteBlock(pos.x, pos.y, pos.z);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// Broadcast block deletion to web clients
|
||||||
|
if (this.server?.broadcastToClients) {
|
||||||
|
this.server.broadcastToClients({
|
||||||
|
type: 'block_deleted',
|
||||||
|
x: pos.x, y: pos.y, z: pos.z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process scan results and update world blocks on the server
|
* Process scan results and update world blocks on the server
|
||||||
*/
|
*/
|
||||||
@@ -484,6 +1179,9 @@ export class Turtle extends EventEmitter {
|
|||||||
if (statusData.inventory) {
|
if (statusData.inventory) {
|
||||||
this._inventory = statusData.inventory;
|
this._inventory = statusData.inventory;
|
||||||
}
|
}
|
||||||
|
if (statusData.label) {
|
||||||
|
this._label = statusData.label;
|
||||||
|
}
|
||||||
|
|
||||||
this._emitUpdate();
|
this._emitUpdate();
|
||||||
}
|
}
|
||||||
@@ -513,6 +1211,9 @@ export class Turtle extends EventEmitter {
|
|||||||
peripherals: this._peripherals,
|
peripherals: this._peripherals,
|
||||||
error: this._error,
|
error: this._error,
|
||||||
warning: this._warning,
|
warning: this._warning,
|
||||||
|
stepsSinceLastRefuel: this._stepsSinceLastRefuel,
|
||||||
|
totalSteps: this._totalSteps,
|
||||||
|
totalFuelUsed: this._totalFuelUsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
226
server/WorldBlockCache.js
Normal file
226
server/WorldBlockCache.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* WorldBlockCache — LRU write-through cache for world blocks
|
||||||
|
*
|
||||||
|
* Replaces the unbounded in-memory Map that previously loaded ALL blocks at
|
||||||
|
* startup. Provides the same Map-like interface (get/set/delete) expected by
|
||||||
|
* DStarLite pathfinder and Turtle._deleteBlockAtPosition, but caps memory
|
||||||
|
* usage at `maxSize` entries and falls through to SQLite on cache miss.
|
||||||
|
*
|
||||||
|
* Bulk reads (initial_state, /api/world/blocks) bypass the cache and query
|
||||||
|
* the database directly via helper methods.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class WorldBlockCache {
|
||||||
|
/**
|
||||||
|
* @param {Object} db - The database module (server/database.js)
|
||||||
|
* @param {number} maxSize - Max entries in the LRU cache (default 50 000)
|
||||||
|
*/
|
||||||
|
constructor(db, maxSize = 50_000) {
|
||||||
|
this._db = db;
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
this._cache = new Map(); // key -> { value, prev, next } (LRU doubly-linked)
|
||||||
|
this._head = null; // most recently used
|
||||||
|
this._tail = null; // least recently used
|
||||||
|
|
||||||
|
// Track total block count from DB (updated lazily)
|
||||||
|
this._dbCount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Map-compatible interface ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a block by "x,y,z" key.
|
||||||
|
* Returns the cached value or falls through to the database.
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
// Cache hit
|
||||||
|
if (this._cache.has(key)) {
|
||||||
|
const node = this._cache.get(key);
|
||||||
|
this._promote(node);
|
||||||
|
return node.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — query DB
|
||||||
|
const [x, y, z] = key.split(',').map(Number);
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
row = this._db.getBlock(x, y, z);
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) return undefined;
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
name: row.name,
|
||||||
|
metadata: row.metadata,
|
||||||
|
discoveredBy: row.discoveredBy,
|
||||||
|
timestamp: row.discovered_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._put(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a block (write-through: updates cache + DB is handled by caller via storeBlock).
|
||||||
|
*/
|
||||||
|
set(key, value) {
|
||||||
|
this._put(key, value);
|
||||||
|
this._dbCount = null; // invalidate count cache
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a block from cache (DB deletion handled by caller).
|
||||||
|
*/
|
||||||
|
delete(key) {
|
||||||
|
if (this._cache.has(key)) {
|
||||||
|
const node = this._cache.get(key);
|
||||||
|
this._unlink(node);
|
||||||
|
this._cache.delete(key);
|
||||||
|
}
|
||||||
|
this._dbCount = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists (checks cache first, then DB).
|
||||||
|
*/
|
||||||
|
has(key) {
|
||||||
|
if (this._cache.has(key)) return true;
|
||||||
|
const [x, y, z] = key.split(',').map(Number);
|
||||||
|
try {
|
||||||
|
return this._db.getBlock(x, y, z) !== null;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate size — returns DB count (not cache size).
|
||||||
|
*/
|
||||||
|
get size() {
|
||||||
|
if (this._dbCount === null) {
|
||||||
|
try {
|
||||||
|
this._dbCount = this._db.getWorldBlockCount();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: count cached entries
|
||||||
|
this._dbCount = this._cache.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._dbCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate all entries — queries DB directly (not bounded by cache).
|
||||||
|
* Used for initial_state and /api/world/blocks.
|
||||||
|
* Returns an iterator of [key, value] pairs.
|
||||||
|
*/
|
||||||
|
*entries() {
|
||||||
|
const rows = this._db.getWorldBlocks(100000);
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.x},${row.y},${row.z}`;
|
||||||
|
yield [key, {
|
||||||
|
name: row.block_name,
|
||||||
|
metadata: row.metadata,
|
||||||
|
discoveredBy: row.discovered_by,
|
||||||
|
timestamp: row.discovered_at,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Bulk helpers for API / WebSocket ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blocks in an area (for spatial queries).
|
||||||
|
*/
|
||||||
|
getBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
||||||
|
return this._db.getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocks formatted for the /api/world/blocks response.
|
||||||
|
* @param {number} limit - Max rows to return
|
||||||
|
*/
|
||||||
|
getAllBlocksForAPI(limit = 100000) {
|
||||||
|
const rows = this._db.getWorldBlocks(limit);
|
||||||
|
return rows.map(row => ({
|
||||||
|
x: row.x, y: row.y, z: row.z,
|
||||||
|
name: row.block_name,
|
||||||
|
metadata: row.metadata,
|
||||||
|
discoveredBy: row.discovered_by,
|
||||||
|
timestamp: row.discovered_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm the cache with blocks near a set of positions (e.g., turtle locations).
|
||||||
|
*/
|
||||||
|
warmArea(cx, cy, cz, radius = 32) {
|
||||||
|
const blocks = this._db.getWorldBlocksInArea(
|
||||||
|
cx - radius, cy - radius, cz - radius,
|
||||||
|
cx + radius, cy + radius, cz + radius,
|
||||||
|
);
|
||||||
|
for (const row of blocks) {
|
||||||
|
const key = `${row.x},${row.y},${row.z}`;
|
||||||
|
if (!this._cache.has(key)) {
|
||||||
|
this._put(key, {
|
||||||
|
name: row.block_name,
|
||||||
|
metadata: row.metadata,
|
||||||
|
discoveredBy: row.discovered_by,
|
||||||
|
timestamp: row.discovered_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Internal LRU ==========
|
||||||
|
|
||||||
|
_put(key, value) {
|
||||||
|
if (this._cache.has(key)) {
|
||||||
|
const node = this._cache.get(key);
|
||||||
|
node.value = value;
|
||||||
|
this._promote(node);
|
||||||
|
} else {
|
||||||
|
const node = { key, value, prev: null, next: null };
|
||||||
|
this._cache.set(key, node);
|
||||||
|
this._addToHead(node);
|
||||||
|
|
||||||
|
// Evict if over capacity
|
||||||
|
if (this._cache.size > this._maxSize) {
|
||||||
|
this._evict();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_promote(node) {
|
||||||
|
if (node === this._head) return;
|
||||||
|
this._unlink(node);
|
||||||
|
this._addToHead(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addToHead(node) {
|
||||||
|
node.prev = null;
|
||||||
|
node.next = this._head;
|
||||||
|
if (this._head) this._head.prev = node;
|
||||||
|
this._head = node;
|
||||||
|
if (!this._tail) this._tail = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
_unlink(node) {
|
||||||
|
if (node.prev) node.prev.next = node.next;
|
||||||
|
else this._head = node.next;
|
||||||
|
if (node.next) node.next.prev = node.prev;
|
||||||
|
else this._tail = node.prev;
|
||||||
|
node.prev = null;
|
||||||
|
node.next = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_evict() {
|
||||||
|
if (!this._tail) return;
|
||||||
|
const evicted = this._tail;
|
||||||
|
this._unlink(evicted);
|
||||||
|
this._cache.delete(evicted.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
server/__tests__/TaskDispatcher.test.js
Normal file
222
server/__tests__/TaskDispatcher.test.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { TaskDispatcher } from '../TaskDispatcher.js';
|
||||||
|
|
||||||
|
// ========== Mock Factories ==========
|
||||||
|
|
||||||
|
function makeTurtle(id, { connected = true, state = 'idle', fuel = 1000, position = null } = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
connected,
|
||||||
|
stateName: state,
|
||||||
|
_fuel: fuel,
|
||||||
|
_error: null,
|
||||||
|
position,
|
||||||
|
setState: vi.fn(function (name) { this.stateName = name; }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDb() {
|
||||||
|
const tasks = [];
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
createTask: vi.fn((type, data, priority, turtleId) => {
|
||||||
|
const id = nextId++;
|
||||||
|
tasks.push({ id, task_type: type, task_data: data, priority, assigned_turtle_id: turtleId, status: 'pending' });
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
getAllTasks: vi.fn((status) => {
|
||||||
|
return tasks
|
||||||
|
.filter(t => !status || t.status === status)
|
||||||
|
.sort((a, b) => b.priority - a.priority || a.id - b.id);
|
||||||
|
}),
|
||||||
|
getNextTask: vi.fn(() => tasks.find(t => t.status === 'pending') || null),
|
||||||
|
assignTask: vi.fn((taskId, turtleId) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) {
|
||||||
|
t.assigned_turtle_id = turtleId;
|
||||||
|
t.status = turtleId === null ? 'pending' : 'assigned';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
updateTaskStatus: vi.fn((taskId, status) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) t.status = status;
|
||||||
|
}),
|
||||||
|
completeTask: vi.fn((taskId) => {
|
||||||
|
const t = tasks.find(x => x.id === taskId);
|
||||||
|
if (t) t.status = 'completed';
|
||||||
|
}),
|
||||||
|
_tasks: tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Tests ==========
|
||||||
|
|
||||||
|
describe('TaskDispatcher', () => {
|
||||||
|
let turtles, db, broadcast, dispatcher;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
turtles = new Map();
|
||||||
|
db = makeDb();
|
||||||
|
broadcast = vi.fn();
|
||||||
|
dispatcher = new TaskDispatcher({ turtles, db, broadcastToClients: broadcast, pollInterval: 60000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch when no turtles are available', () => {
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign a pending task to an idle turtle', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).toHaveBeenCalledWith(1, 1);
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'in_progress');
|
||||||
|
expect(turtle.setState).toHaveBeenCalledWith('mining', expect.objectContaining({ bounds: expect.any(Object) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect turtle assignment on tasks', () => {
|
||||||
|
const t1 = makeTurtle(1);
|
||||||
|
const t2 = makeTurtle(2);
|
||||||
|
turtles.set(1, t1);
|
||||||
|
turtles.set(2, t2);
|
||||||
|
|
||||||
|
// Task assigned specifically to turtle 2
|
||||||
|
db.createTask('explore', { target: { x: 100, y: 64, z: 100 } }, 5, 2);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(t2.setState).toHaveBeenCalled();
|
||||||
|
expect(t1.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip tasks with unknown task_type', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('unknown_type', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(turtle.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not assign to busy turtles', () => {
|
||||||
|
const turtle = makeTurtle(1, { state: 'mining' });
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not assign to disconnected turtles', () => {
|
||||||
|
const turtle = makeTurtle(1, { connected: false });
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.assignTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete task when turtle returns to idle', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 5, maxY: 5, maxZ: 5 } }, 5, null);
|
||||||
|
|
||||||
|
// First tick: assigns
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(turtle.stateName).toBe('mining');
|
||||||
|
|
||||||
|
// Simulate turtle completing
|
||||||
|
turtle.stateName = 'idle';
|
||||||
|
|
||||||
|
// Second tick: reconcile detects idle → complete
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.completeTask).toHaveBeenCalledWith(1);
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_completed', taskId: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-queue task when turtle disconnects', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('explore', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
expect(turtle.stateName).toBe('exploring');
|
||||||
|
|
||||||
|
// Simulate disconnect
|
||||||
|
turtle.connected = false;
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'pending', 'Turtle disconnected');
|
||||||
|
expect(db.assignTask).toHaveBeenCalledWith(1, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pick closest turtle when multiple are available', () => {
|
||||||
|
const t1 = makeTurtle(1, { position: { x: 0, y: 64, z: 0 } });
|
||||||
|
const t2 = makeTurtle(2, { position: { x: 100, y: 64, z: 100 } });
|
||||||
|
turtles.set(1, t1);
|
||||||
|
turtles.set(2, t2);
|
||||||
|
|
||||||
|
db.createTask('transport', { target: { x: 95, y: 64, z: 95 } }, 5, null);
|
||||||
|
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
// t2 is closer to (95,64,95)
|
||||||
|
expect(t2.setState).toHaveBeenCalled();
|
||||||
|
expect(t1.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch when disabled', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', {}, 5, null);
|
||||||
|
|
||||||
|
dispatcher.enabled = false;
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
expect(turtle.setState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel a running task', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
|
||||||
|
db.createTask('mine_area', { bounds: {} }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
dispatcher.cancelTask(1);
|
||||||
|
|
||||||
|
expect(turtle.stateName).toBe('idle');
|
||||||
|
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'cancelled');
|
||||||
|
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_updated', taskId: 1, status: 'cancelled' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report status correctly', () => {
|
||||||
|
const turtle = makeTurtle(1);
|
||||||
|
turtles.set(1, turtle);
|
||||||
|
db.createTask('mine_area', { bounds: {} }, 5, null);
|
||||||
|
dispatcher._tick();
|
||||||
|
|
||||||
|
const status = dispatcher.status();
|
||||||
|
expect(status.enabled).toBe(true);
|
||||||
|
expect(status.activeTasks).toHaveLength(1);
|
||||||
|
expect(status.activeTasks[0].turtleId).toBe(1);
|
||||||
|
expect(status.activeTasks[0].taskId).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
server/__tests__/WorldBlockCache.test.js
Normal file
127
server/__tests__/WorldBlockCache.test.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { WorldBlockCache } from '../WorldBlockCache.js';
|
||||||
|
|
||||||
|
function makeDb() {
|
||||||
|
const blocks = new Map();
|
||||||
|
return {
|
||||||
|
getBlock: vi.fn((x, y, z) => {
|
||||||
|
const key = `${x},${y},${z}`;
|
||||||
|
return blocks.get(key) || null;
|
||||||
|
}),
|
||||||
|
getWorldBlocks: vi.fn((limit) => {
|
||||||
|
const all = [];
|
||||||
|
for (const [key, val] of blocks) {
|
||||||
|
const [x, y, z] = key.split(',').map(Number);
|
||||||
|
all.push({ x, y, z, block_name: val.name, metadata: val.metadata || 0, discovered_by: val.discoveredBy, discovered_at: val.timestamp });
|
||||||
|
if (all.length >= limit) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}),
|
||||||
|
getWorldBlockCount: vi.fn(() => blocks.size),
|
||||||
|
getWorldBlocksInArea: vi.fn(() => []),
|
||||||
|
// Helper for test setup
|
||||||
|
_blocks: blocks,
|
||||||
|
_addBlock(x, y, z, name) {
|
||||||
|
blocks.set(`${x},${y},${z}`, { name, metadata: 0, discoveredBy: 1, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorldBlockCache', () => {
|
||||||
|
let db, cache;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
cache = new WorldBlockCache(db, 5); // Small capacity for testing eviction
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for missing blocks', () => {
|
||||||
|
expect(cache.get('0,0,0')).toBeUndefined();
|
||||||
|
expect(db.getBlock).toHaveBeenCalledWith(0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache blocks from DB on first access', () => {
|
||||||
|
db._addBlock(1, 2, 3, 'minecraft:stone');
|
||||||
|
|
||||||
|
const block = cache.get('1,2,3');
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
expect(block.name).toBe('minecraft:stone');
|
||||||
|
|
||||||
|
// Second access should not hit DB
|
||||||
|
cache.get('1,2,3');
|
||||||
|
expect(db.getBlock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and retrieve blocks', () => {
|
||||||
|
cache.set('5,5,5', { name: 'minecraft:dirt', metadata: 0 });
|
||||||
|
|
||||||
|
const block = cache.get('5,5,5');
|
||||||
|
expect(block.name).toBe('minecraft:dirt');
|
||||||
|
// Should not hit DB since we just set it
|
||||||
|
expect(db.getBlock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete blocks from cache', () => {
|
||||||
|
cache.set('1,1,1', { name: 'minecraft:stone' });
|
||||||
|
expect(cache.delete('1,1,1')).toBe(true);
|
||||||
|
|
||||||
|
// Now should fall through to DB
|
||||||
|
const result = cache.get('1,1,1');
|
||||||
|
expect(db.getBlock).toHaveBeenCalledWith(1, 1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evict LRU entries when over capacity', () => {
|
||||||
|
// Fill cache to capacity (5)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
cache.set(`${i},0,0`, { name: `block_${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access block_0 to make it recently used
|
||||||
|
cache.get('0,0,0');
|
||||||
|
|
||||||
|
// Add one more → should evict the LRU entry (block_1, since block_0 was just accessed)
|
||||||
|
cache.set('5,0,0', { name: 'block_5' });
|
||||||
|
|
||||||
|
// block_1 should have been evicted (we check internal cache size)
|
||||||
|
expect(cache._cache.size).toBe(5);
|
||||||
|
expect(cache._cache.has('1,0,0')).toBe(false);
|
||||||
|
expect(cache._cache.has('0,0,0')).toBe(true); // recently used
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report size from DB count', () => {
|
||||||
|
db._addBlock(1, 1, 1, 'stone');
|
||||||
|
db._addBlock(2, 2, 2, 'dirt');
|
||||||
|
expect(cache.size).toBe(2);
|
||||||
|
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Cached — no second call
|
||||||
|
const _ = cache.size;
|
||||||
|
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invalidate size cache on set/delete', () => {
|
||||||
|
const _ = cache.size;
|
||||||
|
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
cache.set('1,1,1', { name: 'stone' });
|
||||||
|
const __ = cache.size;
|
||||||
|
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should iterate all entries from DB', () => {
|
||||||
|
db._addBlock(1, 1, 1, 'stone');
|
||||||
|
db._addBlock(2, 2, 2, 'dirt');
|
||||||
|
|
||||||
|
const entries = [...cache.entries()];
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(db.getWorldBlocks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return blocks formatted for API', () => {
|
||||||
|
db._addBlock(3, 3, 3, 'minecraft:diamond_ore');
|
||||||
|
|
||||||
|
const blocks = cache.getAllBlocksForAPI(100);
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0]).toMatchObject({ x: 3, y: 3, z: 3, name: 'minecraft:diamond_ore' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,9 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const db = new Database(path.join(__dirname, 'turtle_control.db'));
|
const db = new Database(path.join(__dirname, 'turtle_control.db'));
|
||||||
|
|
||||||
|
// Enable WAL journal mode for better concurrent read/write performance
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Initialize database schema
|
// Initialize database schema
|
||||||
export function initializeDatabase() {
|
export function initializeDatabase() {
|
||||||
// Turtle homes table
|
// Turtle homes table
|
||||||
@@ -82,12 +85,28 @@ export function initializeDatabase() {
|
|||||||
max_x INTEGER NOT NULL,
|
max_x INTEGER NOT NULL,
|
||||||
max_y INTEGER NOT NULL,
|
max_y INTEGER NOT NULL,
|
||||||
max_z INTEGER NOT NULL,
|
max_z INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
color TEXT DEFAULT '#4a8c2a',
|
||||||
status TEXT DEFAULT 'active',
|
status TEXT DEFAULT 'active',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate existing mining_areas table to add name/color columns if missing
|
||||||
|
try {
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
|
||||||
|
const columns = tableInfo.map(c => c.name);
|
||||||
|
if (!columns.includes('name')) {
|
||||||
|
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
|
||||||
|
}
|
||||||
|
if (!columns.includes('color')) {
|
||||||
|
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore migration errors
|
||||||
|
}
|
||||||
|
|
||||||
// Mining statistics table
|
// Mining statistics table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS mining_stats (
|
CREATE TABLE IF NOT EXISTS mining_stats (
|
||||||
@@ -141,10 +160,22 @@ export function initializeDatabase() {
|
|||||||
x INTEGER NOT NULL,
|
x INTEGER NOT NULL,
|
||||||
y INTEGER NOT NULL,
|
y INTEGER NOT NULL,
|
||||||
z INTEGER NOT NULL,
|
z INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate player_positions table to add label column if missing
|
||||||
|
try {
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
|
||||||
|
const columns = tableInfo.map(c => c.name);
|
||||||
|
if (!columns.includes('label')) {
|
||||||
|
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore migration errors
|
||||||
|
}
|
||||||
|
|
||||||
// Chunk analysis table (ore density per chunk)
|
// Chunk analysis table (ore density per chunk)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS chunks (
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
@@ -185,6 +216,16 @@ export function initializeDatabase() {
|
|||||||
ON chunks(x, z);
|
ON chunks(x, z);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Turtle state persistence table (for reconnect recovery)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS turtle_state (
|
||||||
|
turtle_id INTEGER PRIMARY KEY,
|
||||||
|
state_name TEXT NOT NULL,
|
||||||
|
state_data TEXT DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
console.log('✅ Database initialized');
|
console.log('✅ Database initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
|
|||||||
return stmt.all(limit);
|
return stmt.all(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWorldBlockCount() {
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as cnt FROM world_blocks').get();
|
||||||
|
return row ? row.cnt : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT * FROM world_blocks
|
SELECT * FROM world_blocks
|
||||||
@@ -334,12 +380,22 @@ export function getNextTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assignTask(taskId, turtleId) {
|
export function assignTask(taskId, turtleId) {
|
||||||
const stmt = db.prepare(`
|
if (turtleId === null || turtleId === undefined) {
|
||||||
UPDATE task_queue
|
// Un-assign: clear turtle and revert to pending
|
||||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
const stmt = db.prepare(`
|
||||||
WHERE id = ?
|
UPDATE task_queue
|
||||||
`);
|
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
|
||||||
stmt.run(turtleId, Date.now(), taskId);
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(Date.now(), taskId);
|
||||||
|
} else {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE task_queue
|
||||||
|
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(turtleId, Date.now(), taskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTaskStatus(taskId, status, result = null) {
|
export function updateTaskStatus(taskId, status, result = null) {
|
||||||
@@ -381,16 +437,17 @@ export function getAllTasks(status = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mining Areas
|
// Mining Areas
|
||||||
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned') {
|
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, status, created_at, updated_at)
|
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
turtleId,
|
turtleId,
|
||||||
bounds.minX, bounds.minY, bounds.minZ,
|
bounds.minX, bounds.minY, bounds.minZ,
|
||||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||||
|
areaName, color,
|
||||||
status, now, now
|
status, now, now
|
||||||
);
|
);
|
||||||
return result.lastInsertRowid;
|
return result.lastInsertRowid;
|
||||||
@@ -410,6 +467,24 @@ export function updateMiningAreaStatus(areaId, status) {
|
|||||||
stmt.run(status, Date.now(), areaId);
|
stmt.run(status, Date.now(), areaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateMiningArea(areaId, updates) {
|
||||||
|
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
|
||||||
|
const setClauses = [];
|
||||||
|
const values = [];
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (allowedFields.includes(key)) {
|
||||||
|
setClauses.push(`${key} = ?`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (setClauses.length === 0) return;
|
||||||
|
setClauses.push('updated_at = ?');
|
||||||
|
values.push(Date.now());
|
||||||
|
values.push(areaId);
|
||||||
|
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
|
||||||
|
stmt.run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteMiningArea(areaId) {
|
export function deleteMiningArea(areaId) {
|
||||||
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
||||||
return stmt.run(areaId);
|
return stmt.run(areaId);
|
||||||
@@ -553,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Player Positions
|
// Player Positions
|
||||||
export function savePlayerPosition(playerId, position) {
|
export function savePlayerPosition(playerId, position, label = null) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at)
|
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
stmt.run(playerId, position.x, position.y, position.z, Date.now());
|
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlayerPosition(playerId) {
|
export function getPlayerPosition(playerId) {
|
||||||
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?');
|
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
|
||||||
return stmt.get(playerId);
|
const row = stmt.get(playerId);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
playerID: row.player_id,
|
||||||
|
position: { x: row.x, y: row.y, z: row.z },
|
||||||
|
label: row.label,
|
||||||
|
lastUpdate: row.updated_at
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPlayerPositions() {
|
export function getAllPlayerPositions() {
|
||||||
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions');
|
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
|
||||||
return stmt.all();
|
return stmt.all().map(row => ({
|
||||||
|
playerID: row.player_id,
|
||||||
|
position: { x: row.x, y: row.y, z: row.z },
|
||||||
|
label: row.label,
|
||||||
|
lastUpdate: row.updated_at
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
@@ -653,5 +740,40 @@ export function deleteBlocksInArea(fromX, fromY, fromZ, toX, toY, toZ) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== BLOCK DELETION ==========
|
||||||
|
|
||||||
|
export function deleteBlock(x, y, z) {
|
||||||
|
const stmt = db.prepare('DELETE FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||||
|
return stmt.run(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TURTLE STATE PERSISTENCE ==========
|
||||||
|
|
||||||
|
export function saveTurtleState(turtleId, stateName, stateData = {}) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO turtle_state (turtle_id, state_name, state_data, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(turtleId, stateName, JSON.stringify(stateData), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTurtleState(turtleId) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM turtle_state WHERE turtle_id = ?');
|
||||||
|
const row = stmt.get(turtleId);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
stateName: row.state_name,
|
||||||
|
stateData: JSON.parse(row.state_data),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTurtleState(turtleId) {
|
||||||
|
const stmt = db.prepare('DELETE FROM turtle_state WHERE turtle_id = ?');
|
||||||
|
return stmt.run(turtleId);
|
||||||
|
}
|
||||||
|
|
||||||
// Export database instance for custom queries if needed
|
// Export database instance for custom queries if needed
|
||||||
export { db };
|
export { db };
|
||||||
|
|||||||
109
server/helpers/levenshtein.js
Normal file
109
server/helpers/levenshtein.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Levenshtein distance utility
|
||||||
|
* Used for fuzzy matching block/item names for inventory operations.
|
||||||
|
* Inspired by runi95/turtle-control-panel utility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Levenshtein edit distance between two strings.
|
||||||
|
* @param {string} a - First string
|
||||||
|
* @param {string} b - Second string
|
||||||
|
* @returns {number} The edit distance
|
||||||
|
*/
|
||||||
|
export function levenshtein(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
if (a.length === 0) return b.length;
|
||||||
|
if (b.length === 0) return a.length;
|
||||||
|
|
||||||
|
// Use a single-row approach for memory efficiency
|
||||||
|
const aLen = a.length;
|
||||||
|
const bLen = b.length;
|
||||||
|
const row = new Array(bLen + 1);
|
||||||
|
|
||||||
|
// Initialize the first row (distance from empty string to b[0..j])
|
||||||
|
for (let j = 0; j <= bLen; j++) {
|
||||||
|
row[j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= aLen; i++) {
|
||||||
|
let prev = i; // row[0] for this iteration = i
|
||||||
|
for (let j = 1; j <= bLen; j++) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
const val = Math.min(
|
||||||
|
row[j] + 1, // deletion
|
||||||
|
prev + 1, // insertion
|
||||||
|
row[j - 1] + cost // substitution
|
||||||
|
);
|
||||||
|
row[j - 1] = prev;
|
||||||
|
prev = val;
|
||||||
|
}
|
||||||
|
row[bLen] = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row[bLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best match for a query string among candidates using Levenshtein distance.
|
||||||
|
* @param {string} query - The search term
|
||||||
|
* @param {string[]} candidates - Array of candidate strings
|
||||||
|
* @param {number} maxDistance - Maximum acceptable distance (default: Infinity)
|
||||||
|
* @returns {{ match: string|null, distance: number }} Best match and its distance
|
||||||
|
*/
|
||||||
|
export function findBestMatch(query, candidates, maxDistance = Infinity) {
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const lowerCandidate = candidate.toLowerCase();
|
||||||
|
|
||||||
|
// Exact substring match is distance 0
|
||||||
|
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||||
|
const dist = Math.abs(lowerCandidate.length - lowerQuery.length);
|
||||||
|
if (dist < bestDistance) {
|
||||||
|
bestDistance = dist;
|
||||||
|
bestMatch = candidate;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||||
|
if (dist < bestDistance && dist <= maxDistance) {
|
||||||
|
bestDistance = dist;
|
||||||
|
bestMatch = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: bestMatch, distance: bestDistance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all matches within a given distance.
|
||||||
|
* @param {string} query - The search term
|
||||||
|
* @param {string[]} candidates - Array of candidate strings
|
||||||
|
* @param {number} maxDistance - Maximum acceptable distance
|
||||||
|
* @returns {Array<{match: string, distance: number}>} Matches sorted by distance
|
||||||
|
*/
|
||||||
|
export function findAllMatches(query, candidates, maxDistance = 3) {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const matches = [];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const lowerCandidate = candidate.toLowerCase();
|
||||||
|
|
||||||
|
// Exact substring match
|
||||||
|
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||||
|
matches.push({ match: candidate, distance: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||||
|
if (dist <= maxDistance) {
|
||||||
|
matches.push({ match: candidate, distance: dist });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.sort((a, b) => a.distance - b.distance);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"minecraft",
|
"minecraft",
|
||||||
@@ -17,12 +19,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||||
"ws": "^8.14.2",
|
"better-sqlite3": "^9.2.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^9.2.2"
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
796
server/server.js
796
server/server.js
@@ -1,16 +1,34 @@
|
|||||||
import express from 'express';
|
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import cors from 'cors';
|
import { createRequire } from 'module';
|
||||||
import { createServer } from 'http';
|
|
||||||
import * as db from './database.js';
|
import * as db from './database.js';
|
||||||
import { Turtle } from './Turtle.js';
|
import { Turtle } from './Turtle.js';
|
||||||
|
import { TaskDispatcher } from './TaskDispatcher.js';
|
||||||
|
import { WorldBlockCache } from './WorldBlockCache.js';
|
||||||
|
|
||||||
const app = express();
|
const require = createRequire(import.meta.url);
|
||||||
const PORT = 3001;
|
const {
|
||||||
const WS_PORT = 3002;
|
createPlatformServer,
|
||||||
|
setupGracefulShutdown,
|
||||||
|
createProxyEndpoint,
|
||||||
|
} = require('@cc-platform/server');
|
||||||
|
|
||||||
app.use(cors());
|
// ========== Platform Server Setup ==========
|
||||||
app.use(express.json({ limit: '5mb' }));
|
const { app, server, auth, start, port } = createPlatformServer({
|
||||||
|
serviceName: 'turtle-control',
|
||||||
|
port: 3001,
|
||||||
|
cors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { requireAuth } = auth;
|
||||||
|
const API_KEY = process.env.API_KEY || '';
|
||||||
|
|
||||||
|
// Rewrite requests that arrive without /api prefix (from reverse proxy stripping it)
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') {
|
||||||
|
req.url = '/api' + req.url;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
db.initializeDatabase();
|
db.initializeDatabase();
|
||||||
@@ -18,48 +36,33 @@ db.initializeDatabase();
|
|||||||
// Load persisted data from database
|
// Load persisted data from database
|
||||||
console.log('📂 Loading persisted data from database...');
|
console.log('📂 Loading persisted data from database...');
|
||||||
const savedHomes = db.getAllTurtleHomes();
|
const savedHomes = db.getAllTurtleHomes();
|
||||||
const savedBlocks = db.getWorldBlocks();
|
const blockCount = db.getWorldBlockCount();
|
||||||
console.log(` Loaded ${savedHomes.length} turtle homes`);
|
console.log(` Loaded ${savedHomes.length} turtle homes`);
|
||||||
console.log(` Loaded ${savedBlocks.length} world blocks`);
|
console.log(` ${blockCount} world blocks in database (demand-loaded)`);
|
||||||
|
|
||||||
// Store connected web clients and turtle data
|
// Store connected web clients and turtle data
|
||||||
const webClients = new Set();
|
const webClients = new Set();
|
||||||
const turtles = new Map(); // turtleID -> Turtle instance
|
const turtles = new Map(); // turtleID -> Turtle instance
|
||||||
const worldBlocks = new Map(); // "x,y,z" -> {name, metadata, discoveredBy, timestamp}
|
const worldBlocks = new WorldBlockCache(db, parseInt(process.env.BLOCK_CACHE_SIZE || '50000', 10));
|
||||||
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
|
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
|
||||||
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
|
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
|
||||||
|
|
||||||
// Legacy compat: turtleData getter (returns serialized Turtle data)
|
|
||||||
const turtleData = {
|
|
||||||
has(id) { return turtles.has(id); },
|
|
||||||
get(id) { const t = turtles.get(id); return t ? t.toJSON() : undefined; },
|
|
||||||
set(id, _val) { /* no-op, use getOrCreateTurtle */ },
|
|
||||||
delete(id) { return turtles.delete(id); },
|
|
||||||
get size() { return turtles.size; },
|
|
||||||
entries() { return Array.from(turtles.entries()).map(([id, t]) => [id, t.toJSON()])[Symbol.iterator](); },
|
|
||||||
values() { return Array.from(turtles.values()).map(t => t.toJSON())[Symbol.iterator](); },
|
|
||||||
keys() { return turtles.keys(); },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load saved homes into memory
|
// Load saved homes into memory
|
||||||
for (const home of savedHomes) {
|
for (const home of savedHomes) {
|
||||||
turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z });
|
turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved blocks into memory
|
|
||||||
for (const block of savedBlocks) {
|
|
||||||
const key = `${block.x},${block.y},${block.z}`;
|
|
||||||
worldBlocks.set(key, {
|
|
||||||
name: block.block_name,
|
|
||||||
metadata: block.metadata,
|
|
||||||
discoveredBy: block.discovered_by,
|
|
||||||
timestamp: block.discovered_at
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout for considering turtles offline (30 seconds)
|
// Timeout for considering turtles offline (30 seconds)
|
||||||
const TURTLE_TIMEOUT = 30000;
|
const TURTLE_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Task dispatcher — automatically assigns pending tasks to idle turtles
|
||||||
|
const taskDispatcher = new TaskDispatcher({
|
||||||
|
turtles,
|
||||||
|
db,
|
||||||
|
broadcastToClients: (data) => broadcastToClients(data),
|
||||||
|
pollInterval: parseInt(process.env.DISPATCH_INTERVAL || '5000', 10),
|
||||||
|
});
|
||||||
|
|
||||||
// Broadcast to all web clients
|
// Broadcast to all web clients
|
||||||
function broadcastToClients(data) {
|
function broadcastToClients(data) {
|
||||||
const message = JSON.stringify(data);
|
const message = JSON.stringify(data);
|
||||||
@@ -95,15 +98,17 @@ function getOrCreateTurtle(turtleID) {
|
|||||||
turtle.homePosition = savedHome;
|
turtle.homePosition = savedHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-name if no label
|
||||||
|
turtle.autoName();
|
||||||
|
|
||||||
|
// Try to recover previous state from DB (e.g., if turtle rebooted)
|
||||||
|
turtle.recoverState();
|
||||||
|
|
||||||
// ---- Event handlers ----
|
// ---- Event handlers ----
|
||||||
|
|
||||||
// Forward eval commands to webbridge via pending command queue
|
// Forward eval commands to webbridge via WebSocket (or fallback to poll queue)
|
||||||
turtle.on('sendCommand', (command) => {
|
turtle.on('sendCommand', (command) => {
|
||||||
// Queue eval command for webbridge to poll
|
pushCommandToBridge(turtleID, command);
|
||||||
turtle.pendingLegacyCommands.push({
|
|
||||||
...command,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store discovered blocks
|
// Store discovered blocks
|
||||||
@@ -196,32 +201,179 @@ function getBlockPosition(turtlePos, facing, direction) {
|
|||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP server
|
// WebSocket server for web clients AND bridge connections
|
||||||
const server = createServer(app);
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
// WebSocket server for web clients
|
// Track bridge connections separately
|
||||||
const wss = new WebSocketServer({ port: WS_PORT });
|
const bridgeClients = new Set();
|
||||||
|
|
||||||
console.log(`🚀 Turtle Control Server starting...`);
|
/**
|
||||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
* Push a command to the webbridge for a specific turtle.
|
||||||
console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`);
|
* If a bridge is connected via WebSocket, send instantly.
|
||||||
|
* Otherwise, queue for HTTP polling (fallback).
|
||||||
|
*/
|
||||||
|
function pushCommandToBridge(turtleID, command) {
|
||||||
|
let sent = false;
|
||||||
|
for (const bridge of bridgeClients) {
|
||||||
|
if (bridge.readyState === 1) { // OPEN
|
||||||
|
bridge.send(JSON.stringify({
|
||||||
|
type: 'command',
|
||||||
|
turtleID,
|
||||||
|
command,
|
||||||
|
}));
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sent) {
|
||||||
|
// Fallback: queue for HTTP polling (backward compatible)
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (turtle) {
|
||||||
|
turtle.pendingCommands.push({ ...command, timestamp: Date.now() });
|
||||||
|
console.log(`[Bridge] Queued command for T#${turtleID} via HTTP poll (no WS bridge, ${bridgeClients.size} bridges)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket connection handler
|
// WebSocket connection handler
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws, req) => {
|
||||||
|
const url = req.url || '';
|
||||||
|
|
||||||
|
// ---- Bridge WebSocket connection ----
|
||||||
|
if (url.startsWith('/ws/bridge')) {
|
||||||
|
console.log('🌉 Webbridge connected via WebSocket');
|
||||||
|
bridgeClients.add(ws);
|
||||||
|
|
||||||
|
// Send list of known turtle IDs so bridge knows who to listen for
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'init',
|
||||||
|
turtleIDs: Array.from(turtles.keys()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Handle keepalive pings from bridge
|
||||||
|
if (data.type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'status') {
|
||||||
|
// Turtle status update forwarded from bridge
|
||||||
|
const turtleID = data.turtleID;
|
||||||
|
if (!turtleID) return;
|
||||||
|
|
||||||
|
const turtle = getOrCreateTurtle(turtleID);
|
||||||
|
turtle.updateFromStatus(data);
|
||||||
|
|
||||||
|
// Store home position if provided
|
||||||
|
if (data.homePosition) {
|
||||||
|
turtleHomes.set(turtleID, data.homePosition);
|
||||||
|
db.saveTurtleHome(turtleID, data.homePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.type === 'eval_response') {
|
||||||
|
// Eval response from turtle via bridge
|
||||||
|
const turtle = turtles.get(data.turtleID);
|
||||||
|
if (turtle) {
|
||||||
|
const handled = turtle.handleResponse(data.uuid, data.result, data.error);
|
||||||
|
if (handled) {
|
||||||
|
console.log(`📩 Eval response T#${data.turtleID} uuid:${(data.uuid || '').substring(0, 8)} - resolved`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.type === 'event') {
|
||||||
|
// Real-time events (inventory, peripheral)
|
||||||
|
const turtle = turtles.get(data.turtleID);
|
||||||
|
if (turtle) {
|
||||||
|
turtle.handleEvent(data.eventType, data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.type === 'request_home') {
|
||||||
|
// Turtle requesting home position
|
||||||
|
const home = turtleHomes.get(data.turtleID);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'home_position',
|
||||||
|
turtleID: data.turtleID,
|
||||||
|
homePosition: home || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
} else if (data.type === 'set_home') {
|
||||||
|
// Turtle setting home position
|
||||||
|
const pos = data.position;
|
||||||
|
if (pos && data.turtleID) {
|
||||||
|
turtleHomes.set(data.turtleID, pos);
|
||||||
|
db.saveTurtleHome(data.turtleID, pos);
|
||||||
|
const turtle = turtles.get(data.turtleID);
|
||||||
|
if (turtle) turtle.homePosition = pos;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'home_set_confirm',
|
||||||
|
turtleID: data.turtleID,
|
||||||
|
homePosition: pos,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.type === 'blocks_discovered') {
|
||||||
|
// Batch block discovery from turtle
|
||||||
|
if (data.blocks && Array.isArray(data.blocks)) {
|
||||||
|
for (const block of data.blocks) {
|
||||||
|
storeBlock(block.x, block.y, block.z, { name: block.name, metadata: block.metadata || 0 }, data.turtleID || block.discoveredBy);
|
||||||
|
}
|
||||||
|
broadcastToClients({
|
||||||
|
type: 'blocks_discovered',
|
||||||
|
blocks: data.blocks.map(b => ({
|
||||||
|
x: b.x, y: b.y, z: b.z,
|
||||||
|
name: b.name, metadata: b.metadata || 0,
|
||||||
|
discoveredBy: data.turtleID || b.discoveredBy,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.type === 'player_update') {
|
||||||
|
// Player position from pocket computer
|
||||||
|
if (data.playerID && data.position) {
|
||||||
|
db.savePlayerPosition(data.playerID, data.position, data.label || null);
|
||||||
|
broadcastToClients({
|
||||||
|
type: 'player_update',
|
||||||
|
playerID: data.playerID,
|
||||||
|
position: data.position,
|
||||||
|
label: data.label || null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Bridge WS message error:', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('🌉 Webbridge disconnected');
|
||||||
|
bridgeClients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error('❌ Bridge WS error:', error);
|
||||||
|
bridgeClients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Web client WebSocket connection ----
|
||||||
console.log('🌐 New web client connected');
|
console.log('🌐 New web client connected');
|
||||||
webClients.add(ws);
|
webClients.add(ws);
|
||||||
|
|
||||||
// Send current turtle data and world blocks to new client
|
// Send current turtle data and world blocks to new client
|
||||||
const blocks = [];
|
const blocks = worldBlocks.getAllBlocksForAPI();
|
||||||
for (const [key, blockData] of worldBlocks.entries()) {
|
|
||||||
const [x, y, z] = key.split(',').map(Number);
|
|
||||||
blocks.push({ x, y, z, ...blockData });
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'initial_state',
|
type: 'initial_state',
|
||||||
turtles: Array.from(turtles.values()).map(t => t.toJSON()),
|
turtles: Array.from(turtles.values()).map(t => t.toJSON()),
|
||||||
blocks: blocks
|
blocks: blocks,
|
||||||
|
players: db.getAllPlayerPositions()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
@@ -235,20 +387,14 @@ wss.on('connection', (ws) => {
|
|||||||
const turtle = turtles.get(turtleID);
|
const turtle = turtles.get(turtleID);
|
||||||
|
|
||||||
if (turtle) {
|
if (turtle) {
|
||||||
// Check for state change commands
|
// All commands are state changes — server controls all turtle movement
|
||||||
if (data.command === 'set_state' || data.command === 'setState') {
|
if (data.command === 'set_state' || data.command === 'setState') {
|
||||||
const stateName = data.param?.state || data.param;
|
const stateName = data.param?.state || data.param;
|
||||||
const stateData = data.param?.data || {};
|
const stateData = data.param?.data || {};
|
||||||
turtle.setState(stateName, stateData);
|
turtle.setState(stateName, stateData);
|
||||||
console.log(`✅ State set for turtle ${turtleID}: ${stateName}`);
|
console.log(`✅ State set for turtle ${turtleID}: ${stateName}`);
|
||||||
} else {
|
} else {
|
||||||
// Legacy command - queue for webbridge polling
|
console.log(`⚠️ Unrecognized command from web client: ${data.command} — use state machine or server-side action routes`);
|
||||||
turtle.pendingLegacyCommands.push({
|
|
||||||
command: data.command,
|
|
||||||
param: data.param,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
console.log(`✅ Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${JSON.stringify(data.param)})` : '');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`❌ Turtle ${turtleID} not found`);
|
console.log(`❌ Turtle ${turtleID} not found`);
|
||||||
@@ -332,25 +478,23 @@ app.get('/api/turtle/:id/commands', (req, res) => {
|
|||||||
const turtle = turtles.get(turtleID);
|
const turtle = turtles.get(turtleID);
|
||||||
|
|
||||||
if (turtle) {
|
if (turtle) {
|
||||||
// Combine eval commands + legacy commands
|
// Get pending eval commands for webbridge
|
||||||
const commands = turtle.pendingLegacyCommands || [];
|
const commands = turtle.pendingCommands || [];
|
||||||
|
|
||||||
// Clean up old commands (older than 30 seconds)
|
// Clean up old commands (older than 30 seconds)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
turtle.pendingLegacyCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000);
|
turtle.pendingCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||||
|
|
||||||
if (turtle.pendingLegacyCommands.length > 0) {
|
if (turtle.pendingCommands.length > 0) {
|
||||||
console.log(`📤 Sending ${turtle.pendingLegacyCommands.length} command(s) to turtle ${turtleID}`);
|
console.log(`📤 Sending ${turtle.pendingCommands.length} eval command(s) to turtle ${turtleID}`);
|
||||||
turtle.pendingLegacyCommands.forEach(cmd => {
|
turtle.pendingCommands.forEach(cmd => {
|
||||||
if (cmd.type === 'eval') {
|
if (cmd.type === 'eval') {
|
||||||
console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`);
|
console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`);
|
||||||
} else {
|
|
||||||
console.log(` - ${cmd.command}`, cmd.param ? `(${JSON.stringify(cmd.param)})` : '');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ commands: turtle.pendingLegacyCommands });
|
res.json({ commands: turtle.pendingCommands });
|
||||||
} else {
|
} else {
|
||||||
res.json({ commands: [] });
|
res.json({ commands: [] });
|
||||||
}
|
}
|
||||||
@@ -367,11 +511,11 @@ app.post('/api/turtle/:id/commands/ack', (req, res) => {
|
|||||||
const turtle = turtles.get(turtleID);
|
const turtle = turtles.get(turtleID);
|
||||||
|
|
||||||
if (turtle) {
|
if (turtle) {
|
||||||
const clearedCount = (turtle.pendingLegacyCommands || []).length;
|
const clearedCount = (turtle.pendingCommands || []).length;
|
||||||
|
|
||||||
if (clearedCount > 0) {
|
if (clearedCount > 0) {
|
||||||
console.log(`✅ Turtle ${turtleID} acknowledged ${clearedCount} command(s)`);
|
console.log(`✅ Turtle ${turtleID} acknowledged ${clearedCount} command(s)`);
|
||||||
turtle.pendingLegacyCommands = [];
|
turtle.pendingCommands = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, cleared: clearedCount });
|
res.json({ success: true, cleared: clearedCount });
|
||||||
@@ -466,14 +610,7 @@ app.get('/api/turtle/:id/home', (req, res) => {
|
|||||||
|
|
||||||
// Get world blocks for map visualization
|
// Get world blocks for map visualization
|
||||||
app.get('/api/world/blocks', (req, res) => {
|
app.get('/api/world/blocks', (req, res) => {
|
||||||
const blocks = [];
|
const blocks = worldBlocks.getAllBlocksForAPI();
|
||||||
for (const [key, blockData] of worldBlocks.entries()) {
|
|
||||||
const [x, y, z] = key.split(',').map(Number);
|
|
||||||
blocks.push({
|
|
||||||
x, y, z,
|
|
||||||
...blockData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.json({ blocks });
|
res.json({ blocks });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,22 +647,17 @@ app.post('/api/turtle/:id/command', (req, res) => {
|
|||||||
const turtle = turtles.get(turtleID);
|
const turtle = turtles.get(turtleID);
|
||||||
|
|
||||||
if (turtle) {
|
if (turtle) {
|
||||||
// Check for state change commands
|
// All commands are state changes — server controls all turtle movement
|
||||||
if (command === 'set_state' || command === 'setState') {
|
if (command === 'set_state' || command === 'setState') {
|
||||||
const stateName = param?.state || param;
|
const stateName = param?.state || param;
|
||||||
const stateData = param?.data || {};
|
const stateData = param?.data || {};
|
||||||
turtle.setState(stateName, stateData);
|
turtle.setState(stateName, stateData);
|
||||||
console.log(`📤 State set for turtle ${turtleID}: ${stateName}`);
|
console.log(`📤 State set for turtle ${turtleID}: ${stateName}`);
|
||||||
|
res.json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
// Legacy command - queue for webbridge
|
console.log(`⚠️ Legacy command rejected for turtle ${turtleID}: ${command} — use state machine or server-side action routes`);
|
||||||
turtle.pendingLegacyCommands.push({
|
res.status(400).json({ error: 'Legacy commands are no longer supported. Use state machine or action routes.' });
|
||||||
command,
|
|
||||||
param,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
|
|
||||||
}
|
}
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: 'Turtle not found' });
|
res.status(404).json({ error: 'Turtle not found' });
|
||||||
}
|
}
|
||||||
@@ -535,6 +667,46 @@ app.post('/api/turtle/:id/command', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== SERVER-SIDE MOVEMENT & ACTION ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Generic helper for turtle action routes
|
||||||
|
function turtleAction(routePath, actionFn) {
|
||||||
|
app.post(`/api/turtle/:id/${routePath}`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const result = await actionFn(turtle, req.body);
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in /${routePath} for turtle ${req.params.id}:`, error.message);
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movement
|
||||||
|
turtleAction('forward', (t) => t.forward());
|
||||||
|
turtleAction('back', (t) => t.back());
|
||||||
|
turtleAction('up', (t) => t.up());
|
||||||
|
turtleAction('down', (t) => t.down());
|
||||||
|
turtleAction('turnLeft', (t) => t.turnLeft());
|
||||||
|
turtleAction('turnRight', (t) => t.turnRight());
|
||||||
|
|
||||||
|
// Digging
|
||||||
|
turtleAction('dig', (t) => t.dig());
|
||||||
|
turtleAction('digUp', (t) => t.digUp());
|
||||||
|
turtleAction('digDown', (t) => t.digDown());
|
||||||
|
|
||||||
|
// Placing
|
||||||
|
turtleAction('place', (t, body) => t.place(body?.text));
|
||||||
|
turtleAction('placeUp', (t, body) => t.placeUp(body?.text));
|
||||||
|
turtleAction('placeDown', (t, body) => t.placeDown(body?.text));
|
||||||
|
|
||||||
|
// Refuel (via server)
|
||||||
|
turtleAction('refuel-action', (t, body) => t.refuel(body?.count));
|
||||||
|
|
||||||
// ========== EVAL PROTOCOL ENDPOINTS ==========
|
// ========== EVAL PROTOCOL ENDPOINTS ==========
|
||||||
|
|
||||||
// Receive eval response from webbridge (turtle -> webbridge -> server)
|
// Receive eval response from webbridge (turtle -> webbridge -> server)
|
||||||
@@ -878,6 +1050,31 @@ app.post('/api/tasks/:taskId/complete', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel a running task (stops turtle + marks cancelled)
|
||||||
|
app.post('/api/tasks/:taskId/cancel', (req, res) => {
|
||||||
|
try {
|
||||||
|
const taskId = parseInt(req.params.taskId);
|
||||||
|
taskDispatcher.cancelTask(taskId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== TASK DISPATCHER ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Get dispatcher status
|
||||||
|
app.get('/api/dispatcher/status', (req, res) => {
|
||||||
|
res.json(taskDispatcher.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable/disable auto-dispatch
|
||||||
|
app.post('/api/dispatcher/toggle', (req, res) => {
|
||||||
|
const { enabled } = req.body;
|
||||||
|
taskDispatcher.enabled = enabled !== undefined ? enabled : !taskDispatcher.enabled;
|
||||||
|
res.json({ enabled: taskDispatcher.enabled });
|
||||||
|
});
|
||||||
|
|
||||||
// ========== MINING AREA ENDPOINTS ==========
|
// ========== MINING AREA ENDPOINTS ==========
|
||||||
|
|
||||||
// Helper to format mining area for API response
|
// Helper to format mining area for API response
|
||||||
@@ -885,6 +1082,8 @@ function formatMiningArea(area) {
|
|||||||
return {
|
return {
|
||||||
areaID: area.id,
|
areaID: area.id,
|
||||||
turtleID: area.turtle_id,
|
turtleID: area.turtle_id,
|
||||||
|
areaName: area.name || `Area #${area.id}`,
|
||||||
|
color: area.color || '#4a8c2a',
|
||||||
startX: area.min_x,
|
startX: area.min_x,
|
||||||
startY: area.min_y,
|
startY: area.min_y,
|
||||||
startZ: area.min_z,
|
startZ: area.min_z,
|
||||||
@@ -900,7 +1099,7 @@ function formatMiningArea(area) {
|
|||||||
// Save a mining area
|
// Save a mining area
|
||||||
app.post('/api/mining-areas', (req, res) => {
|
app.post('/api/mining-areas', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { turtleId, turtleID, bounds, startX, startY, startZ, endX, endY, endZ, areaName, status } = req.body;
|
const { turtleId, turtleID, bounds, startX, startY, startZ, endX, endY, endZ, areaName, status, color } = req.body;
|
||||||
const tid = turtleId || turtleID;
|
const tid = turtleId || turtleID;
|
||||||
|
|
||||||
// Support both {turtleId, bounds} and flat {startX,startY,...} formats
|
// Support both {turtleId, bounds} and flat {startX,startY,...} formats
|
||||||
@@ -913,7 +1112,7 @@ app.post('/api/mining-areas', (req, res) => {
|
|||||||
maxZ: Math.max(Number(startZ), Number(endZ))
|
maxZ: Math.max(Number(startZ), Number(endZ))
|
||||||
};
|
};
|
||||||
|
|
||||||
db.saveMiningArea(tid, areaBounds, areaName || null, status || 'planned');
|
db.saveMiningArea(tid, areaBounds, areaName || null, status || 'planned', color || '#4a8c2a');
|
||||||
|
|
||||||
const areas = db.getMiningAreas();
|
const areas = db.getMiningAreas();
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
@@ -937,12 +1136,21 @@ app.get('/api/mining-areas', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update mining area status
|
// Update mining area
|
||||||
app.put('/api/mining-areas/:areaId', (req, res) => {
|
app.put('/api/mining-areas/:areaId', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const areaId = parseInt(req.params.areaId);
|
const areaId = parseInt(req.params.areaId);
|
||||||
const { status } = req.body;
|
const { status, name, color } = req.body;
|
||||||
db.updateMiningAreaStatus(areaId, status);
|
|
||||||
|
// Build updates object with only provided fields
|
||||||
|
const updates = {};
|
||||||
|
if (status !== undefined) updates.status = status;
|
||||||
|
if (name !== undefined) updates.name = name;
|
||||||
|
if (color !== undefined) updates.color = color;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
db.updateMiningArea(areaId, updates);
|
||||||
|
}
|
||||||
|
|
||||||
const areas = db.getMiningAreas();
|
const areas = db.getMiningAreas();
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
@@ -1027,17 +1235,63 @@ app.post('/api/chunks', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Analyze a chunk (compute ore density from discovered blocks)
|
||||||
|
app.post('/api/chunks/:x/:z/analyze', (req, res) => {
|
||||||
|
try {
|
||||||
|
const chunkX = parseInt(req.params.x);
|
||||||
|
const chunkZ = parseInt(req.params.z);
|
||||||
|
|
||||||
|
// Chunk bounds in world coordinates (16x16 columns, full Y range)
|
||||||
|
const minX = chunkX * 16;
|
||||||
|
const maxX = minX + 15;
|
||||||
|
const minZ = chunkZ * 16;
|
||||||
|
const maxZ = minZ + 15;
|
||||||
|
|
||||||
|
// Get all blocks in the chunk from the DB
|
||||||
|
const blocks = db.getWorldBlocksInArea(minX, -64, minZ, maxX, 320, maxZ);
|
||||||
|
|
||||||
|
// Count ores
|
||||||
|
const oreCounts = {};
|
||||||
|
let totalBlocks = 0;
|
||||||
|
|
||||||
|
if (blocks && Array.isArray(blocks)) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
totalBlocks++;
|
||||||
|
if (block.block_name && block.block_name.includes('ore')) {
|
||||||
|
oreCounts[block.block_name] = (oreCounts[block.block_name] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = {
|
||||||
|
x: chunkX,
|
||||||
|
z: chunkZ,
|
||||||
|
totalBlocks,
|
||||||
|
ores: oreCounts,
|
||||||
|
scannedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the analysis
|
||||||
|
db.saveChunkAnalysis(chunkX, chunkZ, analysis);
|
||||||
|
|
||||||
|
res.json(analysis);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Search blocks by name pattern in area
|
// Search blocks by name pattern in area
|
||||||
app.get('/api/world/blocks/search', (req, res) => {
|
app.get('/api/world/blocks/search', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { fromX, fromY, fromZ, toX, toY, toZ, pattern } = req.query;
|
const { fromX, fromY, fromZ, toX, toY, toZ, pattern, name } = req.query;
|
||||||
if (!pattern) {
|
const searchPattern = pattern || name;
|
||||||
return res.status(400).json({ error: 'Missing pattern parameter' });
|
if (!searchPattern) {
|
||||||
|
return res.status(400).json({ error: 'Missing pattern or name parameter' });
|
||||||
}
|
}
|
||||||
const blocks = db.getBlocksWithNameLike(
|
const blocks = db.getBlocksWithNameLike(
|
||||||
parseInt(fromX) || -1000, parseInt(fromY) || -64, parseInt(fromZ) || -1000,
|
parseInt(fromX) || -1000, parseInt(fromY) || -64, parseInt(fromZ) || -1000,
|
||||||
parseInt(toX) || 1000, parseInt(toY) || 320, parseInt(toZ) || 1000,
|
parseInt(toX) || 1000, parseInt(toY) || 320, parseInt(toZ) || 1000,
|
||||||
pattern
|
searchPattern
|
||||||
);
|
);
|
||||||
res.json({ blocks });
|
res.json({ blocks });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1259,18 +1513,15 @@ app.post('/api/groups/:groupId/command', (req, res) => {
|
|||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
const turtle = turtles.get(member.turtle_id);
|
const turtle = turtles.get(member.turtle_id);
|
||||||
if (turtle) {
|
if (turtle) {
|
||||||
|
// All group commands are state changes — server controls all movement
|
||||||
if (command === 'set_state' || command === 'setState') {
|
if (command === 'set_state' || command === 'setState') {
|
||||||
const stateName = param?.state || param;
|
const stateName = param?.state || param;
|
||||||
const stateData = param?.data || {};
|
const stateData = param?.data || {};
|
||||||
turtle.setState(stateName, stateData);
|
turtle.setState(stateName, stateData);
|
||||||
|
successCount++;
|
||||||
} else {
|
} else {
|
||||||
turtle.pendingLegacyCommands.push({
|
console.log(`⚠️ Legacy group command rejected: ${command}`);
|
||||||
command,
|
|
||||||
param,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
successCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,19 +1534,20 @@ app.post('/api/groups/:groupId/command', (req, res) => {
|
|||||||
// Player position endpoints
|
// Player position endpoints
|
||||||
app.post('/api/player/update', (req, res) => {
|
app.post('/api/player/update', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { playerID, position, timestamp } = req.body;
|
const { playerID, position, timestamp, label } = req.body;
|
||||||
|
|
||||||
if (!playerID || !position) {
|
if (!playerID || !position) {
|
||||||
return res.status(400).json({ error: 'Missing playerID or position' });
|
return res.status(400).json({ error: 'Missing playerID or position' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.savePlayerPosition(playerID, position);
|
db.savePlayerPosition(playerID, position, label || null);
|
||||||
|
|
||||||
// Broadcast to WebSocket clients
|
// Broadcast to WebSocket clients
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
type: 'player_update',
|
type: 'player_update',
|
||||||
playerID,
|
playerID,
|
||||||
position,
|
position,
|
||||||
|
label: label || null,
|
||||||
timestamp: timestamp || Date.now()
|
timestamp: timestamp || Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1330,15 +1582,327 @@ app.get('/api/player/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// ========== TURTLE ACTION ENDPOINTS ==========
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n🛑 Shutting down server...');
|
// Rename a turtle
|
||||||
db.closeDatabase();
|
app.post('/api/turtle/:id/rename', async (req, res) => {
|
||||||
process.exit(0);
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Missing or invalid name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) {
|
||||||
|
return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await turtle.rename(name.trim());
|
||||||
|
console.log(`📝 Turtle ${turtleID} renamed to "${name.trim()}"`);
|
||||||
|
res.json({ success: true, label: turtle.label });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error renaming turtle ${req.params.id}:`, error.message);
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// Equip item on left side
|
||||||
console.log(`✅ Server ready!`);
|
app.post('/api/turtle/:id/equip-left', async (req, res) => {
|
||||||
console.log(`\nConfigured turtles to send updates to:`);
|
try {
|
||||||
console.log(` http://localhost:${PORT}/api/turtle/update`);
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const result = await turtle.equipLeft();
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Equip item on right side
|
||||||
|
app.post('/api/turtle/:id/equip-right', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const result = await turtle.equipRight();
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select inventory slot
|
||||||
|
app.post('/api/turtle/:id/select', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { slot } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const result = await turtle.select(slot);
|
||||||
|
res.json({ success: true, result, selectedSlot: turtle.selectedSlot });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer items between slots
|
||||||
|
app.post('/api/turtle/:id/transfer', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { fromSlot, toSlot, count } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
await turtle.inventoryTransfer(fromSlot, toSlot, count);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort inventory (compact items on turtle)
|
||||||
|
app.post('/api/turtle/:id/sort', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
// Send a Lua eval that sorts inventory by compacting items
|
||||||
|
const result = await turtle.exec(`
|
||||||
|
local moved = 0
|
||||||
|
for slot = 1, 16 do
|
||||||
|
local item = turtle.getItemDetail(slot)
|
||||||
|
if item then
|
||||||
|
for target = 1, slot - 1 do
|
||||||
|
local targetItem = turtle.getItemDetail(target)
|
||||||
|
if not targetItem then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
break
|
||||||
|
elseif targetItem.name == item.name and targetItem.count < targetItem.maxCount 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 = moved}
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to adjacent inventory (peripheral)
|
||||||
|
app.post('/api/turtle/:id/connect-inventory', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { side } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
if (!side) return res.status(400).json({ error: 'Missing side parameter' });
|
||||||
|
|
||||||
|
const result = await turtle.connectToInventory(side);
|
||||||
|
res.json({ success: true, inventory: result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop items (front/up/down)
|
||||||
|
app.post('/api/turtle/:id/drop', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { direction, count } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (direction === 'up') result = await turtle.dropUp(count);
|
||||||
|
else if (direction === 'down') result = await turtle.dropDown(count);
|
||||||
|
else result = await turtle.drop(count);
|
||||||
|
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suck items (front/up/down)
|
||||||
|
app.post('/api/turtle/:id/suck', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { direction, count } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (direction === 'up') result = await turtle.suckUp(count);
|
||||||
|
else if (direction === 'down') result = await turtle.suckDown(count);
|
||||||
|
else result = await turtle.suck(count);
|
||||||
|
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update turtle config
|
||||||
|
app.post('/api/turtle/:id/config', (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const configData = req.body;
|
||||||
|
|
||||||
|
if (!configData || typeof configData !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid config data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing config
|
||||||
|
const existing = turtleConfig.get(turtleID) || {};
|
||||||
|
const merged = { ...existing, ...configData };
|
||||||
|
turtleConfig.set(turtleID, merged);
|
||||||
|
|
||||||
|
// Persist to database
|
||||||
|
try {
|
||||||
|
db.saveTurtleConfig(turtleID, merged);
|
||||||
|
} catch (e) { /* ignore if method doesn't exist */ }
|
||||||
|
|
||||||
|
console.log(`⚙️ Config updated for turtle ${turtleID}:`, merged);
|
||||||
|
res.json({ success: true, config: merged });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get turtle config
|
||||||
|
app.get('/api/turtle/:id/config', (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const config = turtleConfig.get(turtleID) || {};
|
||||||
|
res.json({ success: true, config });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explore (batched 3-direction inspect)
|
||||||
|
app.post('/api/turtle/:id/explore', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const result = await turtle.explore();
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GPS locate
|
||||||
|
app.post('/api/turtle/:id/gps', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const position = await turtle.gpsLocate();
|
||||||
|
res.json({ success: true, position });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write a file to turtle filesystem
|
||||||
|
app.post('/api/turtle/:id/write-file', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const { path, content, append } = req.body;
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
if (!path || content === undefined) return res.status(400).json({ error: 'Missing path or content' });
|
||||||
|
|
||||||
|
const result = await turtle.writeToFile(path, content, append || false);
|
||||||
|
res.json({ success: result === true, result });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh detailed inventory state
|
||||||
|
app.post('/api/turtle/:id/refresh-inventory', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const turtleID = parseInt(req.params.id);
|
||||||
|
const turtle = turtles.get(turtleID);
|
||||||
|
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
|
||||||
|
|
||||||
|
const inventory = await turtle.refreshInventoryState();
|
||||||
|
res.json({ success: true, inventory });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Cross-Project Integration API ==========
|
||||||
|
// These endpoints allow the Inventory Manager system to query turtle state
|
||||||
|
|
||||||
|
// Get all turtle summaries (for inventory dashboard sidebar widget)
|
||||||
|
app.get('/api/integration/turtle-summary', (req, res) => {
|
||||||
|
const summaries = [];
|
||||||
|
for (const [id, turtle] of turtles) {
|
||||||
|
summaries.push({
|
||||||
|
id,
|
||||||
|
name: turtle.name || `Turtle ${id}`,
|
||||||
|
state: turtle.currentStateName || 'unknown',
|
||||||
|
fuel: turtle.fuelLevel ?? null,
|
||||||
|
position: turtle.position || null,
|
||||||
|
inventoryUsed: turtle.inventory
|
||||||
|
? turtle.inventory.filter(s => s && s.name).length
|
||||||
|
: 0,
|
||||||
|
connected: turtle.connected || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ turtles: summaries });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy to inventory server (locate items for turtle pickup)
|
||||||
|
createProxyEndpoint(app, '/api/integration/inventory-locate', 'INVENTORY_SERVER_URL', '/api/integration/locate-item');
|
||||||
|
|
||||||
|
// Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources)
|
||||||
|
createProxyEndpoint(app, '/api/integration/inventory-alerts', 'INVENTORY_SERVER_URL', '/api/integration/low-stock');
|
||||||
|
|
||||||
|
// Proxy to inventory server (storage space check — should turtles keep mining?)
|
||||||
|
createProxyEndpoint(app, '/api/integration/storage-status', 'INVENTORY_SERVER_URL', '/api/integration/storage-status');
|
||||||
|
|
||||||
|
// ========== Graceful Shutdown & Start ==========
|
||||||
|
|
||||||
|
setupGracefulShutdown({
|
||||||
|
serviceName: 'turtle-control',
|
||||||
|
cleanup: [
|
||||||
|
() => taskDispatcher.stop(),
|
||||||
|
() => db.closeDatabase(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
start(() => {
|
||||||
|
console.log(`\nConfigured turtles to send updates to:`);
|
||||||
|
console.log(` http://localhost:${port}/api/turtle/update`);
|
||||||
|
if (process.env.INVENTORY_SERVER_URL) {
|
||||||
|
console.log(`📦 Inventory server integration: ${process.env.INVENTORY_SERVER_URL}`);
|
||||||
|
}
|
||||||
|
// Start task dispatcher after server is ready
|
||||||
|
taskDispatcher.start();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,13 +130,13 @@ export class BaseState {
|
|||||||
const diff = (targetFacing - currentFacing + 4) % 4;
|
const diff = (targetFacing - currentFacing + 4) % 4;
|
||||||
|
|
||||||
if (diff === 1) {
|
if (diff === 1) {
|
||||||
await this.exec('return turtle.turnRight()');
|
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
|
||||||
this.turtle.facing = targetFacing;
|
this.turtle.facing = targetFacing;
|
||||||
} else if (diff === 2) {
|
} else if (diff === 2) {
|
||||||
await this.exec('turtle.turnRight(); return turtle.turnRight()');
|
await this.exec(`turtle.turnRight(); turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
|
||||||
this.turtle.facing = targetFacing;
|
this.turtle.facing = targetFacing;
|
||||||
} else if (diff === 3) {
|
} else if (diff === 3) {
|
||||||
await this.exec('return turtle.turnLeft()');
|
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${targetFacing}`);
|
||||||
this.turtle.facing = targetFacing;
|
this.turtle.facing = targetFacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,55 @@
|
|||||||
/**
|
/**
|
||||||
* DumpInventoryState - Dump inventory into nearby containers
|
* DumpInventoryState - Dump inventory into nearby containers
|
||||||
* Keeps fuel items, dumps everything else
|
* 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 { BaseState } from './BaseState.js';
|
||||||
|
import { findBestMatch } from '../helpers/levenshtein.js';
|
||||||
|
|
||||||
|
// Items to keep (fuel sources and tools)
|
||||||
const KEEP_ITEMS = new Set([
|
const KEEP_ITEMS = new Set([
|
||||||
'minecraft:coal', 'minecraft:charcoal', 'minecraft:coal_block',
|
'minecraft:coal', 'minecraft:charcoal', 'minecraft:coal_block',
|
||||||
'minecraft:torch', 'minecraft:lava_bucket',
|
'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 {
|
export class DumpInventoryState extends BaseState {
|
||||||
constructor(turtle, data = {}) {
|
constructor(turtle, data = {}) {
|
||||||
super(turtle, data);
|
super(turtle, data);
|
||||||
this.returnState = data.returnState || null;
|
this.returnState = data.returnState || null;
|
||||||
this.returnData = data.returnData || {};
|
this.returnData = data.returnData || {};
|
||||||
|
this.navigateHome = data.navigateHome || false;
|
||||||
|
this.keepItems = data.keepItems
|
||||||
|
? new Set([...KEEP_ITEMS, ...data.keepItems])
|
||||||
|
: KEEP_ITEMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@@ -21,47 +57,85 @@ export class DumpInventoryState extends BaseState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get description() {
|
get description() {
|
||||||
return 'Dumping inventory';
|
return this.navigateHome ? 'Returning home to dump inventory' : 'Dumping inventory';
|
||||||
}
|
}
|
||||||
|
|
||||||
async *act() {
|
async *act() {
|
||||||
console.log(`[${this.turtle.id}] Starting inventory dump`);
|
console.log(`[${this.turtle.id}] Starting inventory dump`);
|
||||||
|
|
||||||
// Try to dump into containers in all directions
|
// Optionally navigate home first
|
||||||
const keepItems = [...KEEP_ITEMS].map(n => `"${n}"`).join(', ');
|
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(`
|
const result = await this.exec(`
|
||||||
local keepItems = {${keepItems}}
|
local keepItems = {${keepItemsList}}
|
||||||
local keepSet = {}
|
local keepSet = {}
|
||||||
for _, name in ipairs(keepItems) do keepSet[name] = true end
|
for _, name in ipairs(keepItems) do keepSet[name] = true end
|
||||||
|
|
||||||
local dropFns = {
|
local dropFns = {${dropFnEntries.join(', ')}}
|
||||||
turtle.drop,
|
|
||||||
turtle.dropUp,
|
|
||||||
turtle.dropDown,
|
|
||||||
}
|
|
||||||
|
|
||||||
local dumpedCount = 0
|
local dumpedCount = 0
|
||||||
|
local failedCount = 0
|
||||||
|
|
||||||
-- Try each direction
|
|
||||||
for _, dropFn in ipairs(dropFns) do
|
for _, dropFn in ipairs(dropFns) do
|
||||||
for slot = 1, 16 do
|
for slot = 1, 16 do
|
||||||
local item = turtle.getItemDetail(slot)
|
local item = turtle.getItemDetail(slot)
|
||||||
if item and not keepSet[item.name] then
|
if item and not keepSet[item.name] then
|
||||||
turtle.select(slot)
|
turtle.select(slot)
|
||||||
|
local prevCount = item.count
|
||||||
if dropFn() then
|
if dropFn() then
|
||||||
dumpedCount = dumpedCount + item.count
|
local remaining = turtle.getItemCount(slot)
|
||||||
|
dumpedCount = dumpedCount + (prevCount - remaining)
|
||||||
|
else
|
||||||
|
failedCount = failedCount + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
turtle.select(1)
|
turtle.select(1)
|
||||||
return {dumped = dumpedCount}
|
return {dumped = dumpedCount, failed = failedCount}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
console.log(`[${this.turtle.id}] Dumped ${result.dumped} items`);
|
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;
|
yield;
|
||||||
@@ -69,7 +143,49 @@ export class DumpInventoryState extends BaseState {
|
|||||||
// Update inventory after dumping
|
// Update inventory after dumping
|
||||||
await this.getInventory();
|
await this.getInventory();
|
||||||
|
|
||||||
// Transition to next state
|
// 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) {
|
if (this.returnState) {
|
||||||
this.turtle.setState(this.returnState, this.returnData);
|
this.turtle.setState(this.returnState, this.returnData);
|
||||||
} else {
|
} else {
|
||||||
@@ -83,6 +199,10 @@ export class DumpInventoryState extends BaseState {
|
|||||||
data: {
|
data: {
|
||||||
returnState: this.returnState,
|
returnState: this.returnState,
|
||||||
returnData: this.returnData,
|
returnData: this.returnData,
|
||||||
|
navigateHome: this.navigateHome,
|
||||||
|
keepItems: this.keepItems !== KEEP_ITEMS
|
||||||
|
? [...this.keepItems].filter(i => !KEEP_ITEMS.has(i))
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ExploringState - Autonomous exploration to discover the world map
|
* ExploringState - Chunk-based spiral exploration to discover the world map
|
||||||
* Similar to mining but focused on discovering blocks rather than mining them
|
*
|
||||||
|
* Instead of random walking, this systematically explores in a spiral pattern
|
||||||
|
* outward from the starting position, chunk by chunk (16x16 areas).
|
||||||
|
* Within each chunk, it does a strip-mine pattern to scan all blocks.
|
||||||
|
*
|
||||||
|
* Inspired by runi95/turtle-control-panel's exploration pattern.
|
||||||
*/
|
*/
|
||||||
import { BaseState } from './BaseState.js';
|
import { BaseState } from './BaseState.js';
|
||||||
|
|
||||||
@@ -9,9 +14,19 @@ export class ExploringState extends BaseState {
|
|||||||
super(turtle, data);
|
super(turtle, data);
|
||||||
this.maxDistance = data.maxDistance || 200;
|
this.maxDistance = data.maxDistance || 200;
|
||||||
this.minFuel = data.minFuel || 500;
|
this.minFuel = data.minFuel || 500;
|
||||||
|
this.yLevel = data.yLevel || null; // null = stay at current Y
|
||||||
this.blocksDiscovered = 0;
|
this.blocksDiscovered = 0;
|
||||||
this.stuckCounter = 0;
|
this.chunksExplored = 0;
|
||||||
this.visitedPositions = new Set();
|
|
||||||
|
// Spiral state
|
||||||
|
this.spiralRing = data.spiralRing || 0;
|
||||||
|
this.spiralSide = data.spiralSide || 0;
|
||||||
|
this.spiralStep = data.spiralStep || 0;
|
||||||
|
this.spiralChunkX = data.spiralChunkX ?? null;
|
||||||
|
this.spiralChunkZ = data.spiralChunkZ ?? null;
|
||||||
|
this.startChunkX = data.startChunkX ?? null;
|
||||||
|
this.startChunkZ = data.startChunkZ ?? null;
|
||||||
|
this.exploredChunks = new Set(data.exploredChunks || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@@ -19,56 +34,235 @@ export class ExploringState extends BaseState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get description() {
|
get description() {
|
||||||
return `Exploring - ${this.blocksDiscovered} blocks discovered`;
|
return `Exploring - ${this.blocksDiscovered} blocks, ${this.chunksExplored} chunks (ring ${this.spiralRing})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async *act() {
|
async *act() {
|
||||||
console.log(`[${this.turtle.id}] Starting exploration`);
|
const pos = this.turtle.position;
|
||||||
|
if (!pos) {
|
||||||
|
console.log(`[${this.turtle.id}] No position, trying GPS...`);
|
||||||
|
await this.turtle.gpsLocate();
|
||||||
|
if (!this.turtle.position) {
|
||||||
|
console.error(`[${this.turtle.id}] Cannot explore without position`);
|
||||||
|
this.turtle.setState('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize start chunk from current position
|
||||||
|
if (this.startChunkX === null) {
|
||||||
|
this.startChunkX = Math.floor(this.turtle.position.x / 16);
|
||||||
|
this.startChunkZ = Math.floor(this.turtle.position.z / 16);
|
||||||
|
this.spiralChunkX = this.startChunkX;
|
||||||
|
this.spiralChunkZ = this.startChunkZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Y level for exploration
|
||||||
|
if (!this.yLevel) {
|
||||||
|
this.yLevel = this.turtle.position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${this.turtle.id}] Starting chunk-based spiral exploration from chunk (${this.startChunkX}, ${this.startChunkZ}), Y=${this.yLevel}`);
|
||||||
|
|
||||||
while (!this.cancelled) {
|
while (!this.cancelled) {
|
||||||
// Safety checks
|
try {
|
||||||
const fuel = await this.checkFuel();
|
// Safety: check fuel
|
||||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
const fuel = await this.checkFuel();
|
||||||
const refueled = await this.tryRefuel();
|
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||||
if (!refueled) {
|
const refueled = await this.tryRefuel();
|
||||||
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
if (!refueled) {
|
||||||
|
console.log(`[${this.turtle.id}] Low fuel, going home`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: check inventory
|
||||||
|
const isFull = await this.isInventoryFull();
|
||||||
|
if (isFull) {
|
||||||
|
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety: check distance
|
||||||
|
if (this._isTooFar()) {
|
||||||
|
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current target chunk
|
||||||
|
const chunkKey = `${this.spiralChunkX},${this.spiralChunkZ}`;
|
||||||
|
|
||||||
|
if (!this.exploredChunks.has(chunkKey)) {
|
||||||
|
// Explore this chunk
|
||||||
|
console.log(`[${this.turtle.id}] Exploring chunk (${this.spiralChunkX}, ${this.spiralChunkZ}) [ring ${this.spiralRing}]`);
|
||||||
|
yield* this._exploreChunk(this.spiralChunkX, this.spiralChunkZ);
|
||||||
|
this.exploredChunks.add(chunkKey);
|
||||||
|
this.chunksExplored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance spiral to next chunk
|
||||||
|
this._advanceSpiral();
|
||||||
|
|
||||||
|
yield;
|
||||||
|
} catch (error) {
|
||||||
|
const isTimeout = error.message?.includes('timed out');
|
||||||
|
if (isTimeout) {
|
||||||
|
console.warn(`[${this.turtle.id}] Exploration timeout, retrying...`);
|
||||||
|
await this._sleep(3000);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explore a single chunk by navigating to it and doing a strip-mine scan pattern.
|
||||||
|
* Scans every 3rd row at the exploration Y level for good coverage.
|
||||||
|
*/
|
||||||
|
async *_exploreChunk(chunkX, chunkZ) {
|
||||||
|
const startX = chunkX * 16;
|
||||||
|
const startZ = chunkZ * 16;
|
||||||
|
|
||||||
|
// Navigate to the start corner of the chunk
|
||||||
|
const chunkStart = { x: startX, y: this.yLevel, z: startZ };
|
||||||
|
yield* this._navigateToSafe(chunkStart);
|
||||||
|
|
||||||
|
// Strip-mine scan pattern across the chunk
|
||||||
|
// Every 3 blocks gives good coverage with turtle.inspect() range
|
||||||
|
let forward = true;
|
||||||
|
for (let row = 0; row < 16; row += 3) {
|
||||||
|
if (this.cancelled) return;
|
||||||
|
|
||||||
|
const rowZ = startZ + row;
|
||||||
|
const rowStartX = forward ? startX : startX + 15;
|
||||||
|
|
||||||
|
// Navigate to row start
|
||||||
|
yield* this._navigateToSafe({ x: rowStartX, y: this.yLevel, z: rowZ });
|
||||||
|
|
||||||
|
// Walk along the row, scanning as we go
|
||||||
|
const direction = forward ? 1 : 3; // East or West
|
||||||
|
await this.turnToFace(direction);
|
||||||
|
|
||||||
|
for (let step = 0; step < 15; step++) {
|
||||||
|
if (this.cancelled) return;
|
||||||
|
|
||||||
|
// 3-direction scan
|
||||||
|
const scanResult = await this.exec(`
|
||||||
|
local results = {}
|
||||||
|
local h, d
|
||||||
|
h, d = turtle.inspect()
|
||||||
|
if h then results.forward = d end
|
||||||
|
h, d = turtle.inspectUp()
|
||||||
|
if h then results.up = d end
|
||||||
|
h, d = turtle.inspectDown()
|
||||||
|
if h then results.down = d end
|
||||||
|
return results
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (scanResult && typeof scanResult === 'object') {
|
||||||
|
this.turtle.processScanResults(scanResult);
|
||||||
|
this.blocksDiscovered += Object.keys(scanResult).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mine any valuable ores we find
|
||||||
|
yield* this._checkAndMineOres();
|
||||||
|
|
||||||
|
// Move forward (digging through obstacles)
|
||||||
|
const moved = await this.moveForward(true);
|
||||||
|
if (!moved) {
|
||||||
|
// Stuck - try to go over
|
||||||
|
await this.moveUp(true);
|
||||||
|
const movedOver = await this.moveForward(true);
|
||||||
|
if (movedOver) {
|
||||||
|
await this.moveDown(true);
|
||||||
|
} else {
|
||||||
|
// Really stuck, skip rest of row
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield;
|
||||||
|
await this._sleep(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check distance
|
// Alternate direction for serpentine pattern
|
||||||
if (this._isTooFar()) {
|
forward = !forward;
|
||||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
}
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check inventory
|
/**
|
||||||
const isFull = await this.isInventoryFull();
|
* Navigate to a target position safely using pathfinding with simple fallback
|
||||||
if (isFull) {
|
*/
|
||||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring' });
|
async *_navigateToSafe(target) {
|
||||||
return;
|
try {
|
||||||
}
|
const success = yield* this.navigateTo(target, { canMine: true, maxAttempts: 500 });
|
||||||
|
if (success) return;
|
||||||
|
} catch (error) {
|
||||||
|
// Pathfinding failed, use simple navigation
|
||||||
|
}
|
||||||
|
|
||||||
// Mark position
|
// Fallback: simple direct navigation
|
||||||
|
yield* this._simpleNavigate(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple direct navigation (X -> Z -> Y)
|
||||||
|
*/
|
||||||
|
async *_simpleNavigate(target) {
|
||||||
|
const maxAttempts = 300;
|
||||||
|
let attempts = 0;
|
||||||
|
let stuckCount = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts && !this.cancelled) {
|
||||||
const pos = this.turtle.position;
|
const pos = this.turtle.position;
|
||||||
if (pos) {
|
if (!pos) return;
|
||||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
|
||||||
|
const dx = target.x - pos.x;
|
||||||
|
const dy = target.y - pos.y;
|
||||||
|
const dz = target.z - pos.z;
|
||||||
|
|
||||||
|
// Close enough (within 2 blocks)
|
||||||
|
if (Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && Math.abs(dz) <= 1) return;
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
// Move in X direction
|
||||||
|
if (Math.abs(dx) > 1) {
|
||||||
|
const facing = dx > 0 ? 1 : 3;
|
||||||
|
await this.turnToFace(facing);
|
||||||
|
moved = await this.moveForward(true);
|
||||||
|
}
|
||||||
|
// Then Z direction
|
||||||
|
else if (Math.abs(dz) > 1) {
|
||||||
|
const facing = dz > 0 ? 2 : 0;
|
||||||
|
await this.turnToFace(facing);
|
||||||
|
moved = await this.moveForward(true);
|
||||||
|
}
|
||||||
|
// Then Y direction
|
||||||
|
else if (dy > 0) {
|
||||||
|
moved = await this.moveUp(true);
|
||||||
|
} else if (dy < 0) {
|
||||||
|
moved = await this.moveDown(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comprehensive scan
|
if (!moved) {
|
||||||
const scanResult = await this.scanSurroundings();
|
stuckCount++;
|
||||||
if (scanResult) {
|
if (stuckCount > 5) {
|
||||||
this.blocksDiscovered += Object.keys(scanResult).length;
|
// Try going up and over
|
||||||
|
await this.moveUp(true);
|
||||||
|
await this.moveUp(true);
|
||||||
|
stuckCount = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stuckCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mine any valuable ores we find
|
|
||||||
yield* this._checkAndMineOres();
|
|
||||||
|
|
||||||
// Exploration movement
|
|
||||||
yield* this._exploreStep();
|
|
||||||
|
|
||||||
yield;
|
yield;
|
||||||
await this._sleep(300);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +270,13 @@ export class ExploringState extends BaseState {
|
|||||||
const valuableOres = new Set([
|
const valuableOres = new Set([
|
||||||
'minecraft:diamond_ore', 'minecraft:emerald_ore',
|
'minecraft:diamond_ore', 'minecraft:emerald_ore',
|
||||||
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
|
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
|
||||||
|
'minecraft:gold_ore', 'minecraft:deepslate_gold_ore',
|
||||||
|
'minecraft:iron_ore', 'minecraft:deepslate_iron_ore',
|
||||||
|
'minecraft:lapis_ore', 'minecraft:deepslate_lapis_ore',
|
||||||
|
'minecraft:redstone_ore', 'minecraft:deepslate_redstone_ore',
|
||||||
|
'minecraft:copper_ore', 'minecraft:deepslate_copper_ore',
|
||||||
|
'minecraft:coal_ore', 'minecraft:deepslate_coal_ore',
|
||||||
|
'minecraft:ancient_debris',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const directions = [
|
const directions = [
|
||||||
@@ -100,61 +301,54 @@ export class ExploringState extends BaseState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async *_exploreStep() {
|
/**
|
||||||
const pos = this.turtle.position;
|
* Advance the spiral to the next chunk position.
|
||||||
if (!pos) return;
|
* Standard clockwise spiral: start at center, go E, then S, W, N with increasing lengths.
|
||||||
|
* Ring 0: just center
|
||||||
// Favor horizontal movement heavily (85%)
|
* Ring 1: side lengths = 1,2,2,1 (total 6 chunks)
|
||||||
const r = Math.random() * 100;
|
* Ring R: 8*R chunks around the ring
|
||||||
|
*/
|
||||||
if (r < 85) {
|
_advanceSpiral() {
|
||||||
// Try to find unvisited direction
|
if (this.spiralRing === 0) {
|
||||||
let moved = false;
|
// Center done, start ring 1 going East
|
||||||
|
this.spiralRing = 1;
|
||||||
for (let i = 0; i < 4; i++) {
|
this.spiralSide = 0;
|
||||||
const fwdPos = this.turtle.getBlockPositionInDirection('forward');
|
this.spiralStep = 0;
|
||||||
if (fwdPos && !this.visitedPositions.has(`${fwdPos.x},${fwdPos.y},${fwdPos.z}`)) {
|
this.spiralChunkX = this.startChunkX + 1;
|
||||||
const canMove = await this.exec('local h = turtle.inspect(); return not h');
|
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||||
if (canMove) {
|
return;
|
||||||
await this.moveForward(false);
|
|
||||||
moved = true;
|
|
||||||
this.stuckCounter = 0;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// Block in the way - dig through if exploring
|
|
||||||
const success = await this.moveForward(true);
|
|
||||||
if (success) {
|
|
||||||
moved = true;
|
|
||||||
this.stuckCounter = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.exec('turtle.turnRight()');
|
|
||||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!moved) {
|
|
||||||
this.stuckCounter++;
|
|
||||||
const success = await this.moveForward(true);
|
|
||||||
if (!success) {
|
|
||||||
await this.exec('turtle.turnRight()');
|
|
||||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (r < 93 && pos.y > 10) {
|
|
||||||
await this.moveDown(true);
|
|
||||||
} else {
|
|
||||||
await this.moveUp(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.stuckCounter > 6) {
|
// Side directions: S, W, N, E
|
||||||
await this.moveUp(true);
|
const sideDirs = [
|
||||||
this.stuckCounter = 0;
|
{ dx: 0, dz: 1 }, // South
|
||||||
|
{ dx: -1, dz: 0 }, // West
|
||||||
|
{ dx: 0, dz: -1 }, // North
|
||||||
|
{ dx: 1, dz: 0 }, // East
|
||||||
|
];
|
||||||
|
|
||||||
|
this.spiralStep++;
|
||||||
|
const sideLength = this.spiralRing * 2;
|
||||||
|
|
||||||
|
if (this.spiralStep >= sideLength) {
|
||||||
|
this.spiralStep = 0;
|
||||||
|
this.spiralSide++;
|
||||||
|
|
||||||
|
if (this.spiralSide >= 4) {
|
||||||
|
// Done with this ring, move to next
|
||||||
|
this.spiralRing++;
|
||||||
|
this.spiralSide = 0;
|
||||||
|
// Jump to start of new ring (one East, one North from current)
|
||||||
|
this.spiralChunkX = this.startChunkX + this.spiralRing;
|
||||||
|
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yield;
|
// Move in current side direction
|
||||||
|
const dir = sideDirs[this.spiralSide];
|
||||||
|
this.spiralChunkX += dir.dx;
|
||||||
|
this.spiralChunkZ += dir.dz;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isTooFar() {
|
_isTooFar() {
|
||||||
@@ -171,6 +365,15 @@ export class ExploringState extends BaseState {
|
|||||||
data: {
|
data: {
|
||||||
maxDistance: this.maxDistance,
|
maxDistance: this.maxDistance,
|
||||||
minFuel: this.minFuel,
|
minFuel: this.minFuel,
|
||||||
|
yLevel: this.yLevel,
|
||||||
|
spiralRing: this.spiralRing,
|
||||||
|
spiralSide: this.spiralSide,
|
||||||
|
spiralStep: this.spiralStep,
|
||||||
|
spiralChunkX: this.spiralChunkX,
|
||||||
|
spiralChunkZ: this.spiralChunkZ,
|
||||||
|
startChunkX: this.startChunkX,
|
||||||
|
startChunkZ: this.startChunkZ,
|
||||||
|
exploredChunks: [...this.exploredChunks],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ export class IdleState extends BaseState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *act() {
|
async *act() {
|
||||||
// Send periodic status updates while idle
|
// Periodic fuel check while idle (no scanning — avoids rotating turtle)
|
||||||
|
// Errors are caught here so they don't bubble up and trigger
|
||||||
|
// the _runStateLoop retry mechanism (which would create an
|
||||||
|
// infinite idle→timeout→idle loop).
|
||||||
while (!this.cancelled) {
|
while (!this.cancelled) {
|
||||||
await this.checkFuel();
|
try {
|
||||||
await this.scanSurroundings();
|
await this.checkFuel();
|
||||||
|
} catch (e) {
|
||||||
|
// Fuel check failed (likely command timeout) — not critical in idle
|
||||||
|
// Just wait longer before trying again
|
||||||
|
await this._sleep(30000);
|
||||||
|
}
|
||||||
yield;
|
yield;
|
||||||
await this._sleep(5000);
|
await this._sleep(10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,48 +40,59 @@ export class MiningState extends BaseState {
|
|||||||
console.log(`[${this.turtle.id}] Starting mining operation`);
|
console.log(`[${this.turtle.id}] Starting mining operation`);
|
||||||
|
|
||||||
while (!this.cancelled) {
|
while (!this.cancelled) {
|
||||||
// Safety checks
|
try {
|
||||||
const fuel = await this.checkFuel();
|
// Safety checks
|
||||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
const fuel = await this.checkFuel();
|
||||||
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
|
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||||
const refueled = await this.tryRefuel();
|
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
|
||||||
if (!refueled) {
|
const refueled = await this.tryRefuel();
|
||||||
console.log(`[${this.turtle.id}] Cannot refuel, going home`);
|
if (!refueled) {
|
||||||
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
console.log(`[${this.turtle.id}] Cannot refuel, going home`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check inventory
|
||||||
|
const isFull = await this.isInventoryFull();
|
||||||
|
if (isFull) {
|
||||||
|
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'mining', returnData: this.data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check distance from home
|
||||||
|
if (this._isTooFar()) {
|
||||||
|
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||||
|
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark current position as visited
|
||||||
|
const pos = this.turtle.position;
|
||||||
|
if (pos) {
|
||||||
|
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan surroundings for ores
|
||||||
|
const scanResult = await this.scanSurroundings();
|
||||||
|
|
||||||
|
// Mine any ores found in surroundings
|
||||||
|
yield* this._mineAdjacentOres();
|
||||||
|
|
||||||
|
// Explore step - try to find new areas
|
||||||
|
yield* this._exploreStep();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const isTimeout = error.message?.includes('timed out');
|
||||||
|
if (isTimeout) {
|
||||||
|
console.warn(`[${this.turtle.id}] Mining exec timeout, will retry next iteration`);
|
||||||
|
await this._sleep(3000);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check inventory
|
|
||||||
const isFull = await this.isInventoryFull();
|
|
||||||
if (isFull) {
|
|
||||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
|
||||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'mining', returnData: this.data });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check distance from home
|
|
||||||
if (this._isTooFar()) {
|
|
||||||
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
|
||||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark current position as visited
|
|
||||||
const pos = this.turtle.position;
|
|
||||||
if (pos) {
|
|
||||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan surroundings for ores
|
|
||||||
const scanResult = await this.scanSurroundings();
|
|
||||||
|
|
||||||
// Mine any ores found in surroundings
|
|
||||||
yield* this._mineAdjacentOres();
|
|
||||||
|
|
||||||
// Explore step - try to find new areas
|
|
||||||
yield* this._exploreStep();
|
|
||||||
|
|
||||||
yield;
|
yield;
|
||||||
await this._sleep(200);
|
await this._sleep(200);
|
||||||
}
|
}
|
||||||
|
|||||||
767
turtle.lua
767
turtle.lua
@@ -1,51 +1,39 @@
|
|||||||
-- Advanced Autonomous Mining Turtle v4 (Eval/Response Protocol)
|
-- Server-Driven Turtle v5 (Pure Eval Protocol)
|
||||||
-- Features: Server-driven state machine, UUID command correlation,
|
-- All behavior is driven by the server via eval commands.
|
||||||
-- GPS tracking, local fallback intelligence, block scanning
|
-- This script only handles: eval execution, status broadcasting,
|
||||||
|
-- GPS tracking, inventory/peripheral events.
|
||||||
|
|
||||||
local CHANNEL_RECEIVE = 100
|
local Channels = require('platform.channels')
|
||||||
local CHANNEL_SEND = 101
|
|
||||||
local STATUS_CHANNEL = 102
|
|
||||||
|
|
||||||
-- State tracking (lightweight - server drives behavior via eval)
|
local CHANNEL_RECEIVE = Channels.get('remoteturtle.command')
|
||||||
|
local CHANNEL_SEND = Channels.get('remoteturtle.response')
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
|
||||||
|
-- State tracking (lightweight - server drives everything)
|
||||||
local state = {
|
local state = {
|
||||||
mode = "idle",
|
|
||||||
position = nil,
|
position = nil,
|
||||||
homePosition = nil,
|
homePosition = nil,
|
||||||
fuel = 0,
|
fuel = 0,
|
||||||
inventory = {},
|
inventory = {},
|
||||||
facing = 0,
|
facing = 0,
|
||||||
lastStatusUpdate = 0,
|
lastStatusUpdate = 0,
|
||||||
evalSupported = true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Configuration
|
-- Configuration
|
||||||
local config = {
|
local config = {
|
||||||
statusUpdateInterval = 5,
|
statusUpdateInterval = 5,
|
||||||
valuableBlocks = {
|
|
||||||
["minecraft:coal_ore"] = 1,
|
|
||||||
["minecraft:iron_ore"] = 2,
|
|
||||||
["minecraft:gold_ore"] = 3,
|
|
||||||
["minecraft:diamond_ore"] = 5,
|
|
||||||
["minecraft:emerald_ore"] = 5,
|
|
||||||
["minecraft:redstone_ore"] = 2,
|
|
||||||
["minecraft:lapis_ore"] = 2,
|
|
||||||
["minecraft:deepslate_coal_ore"] = 1,
|
|
||||||
["minecraft:deepslate_iron_ore"] = 2,
|
|
||||||
["minecraft:deepslate_gold_ore"] = 3,
|
|
||||||
["minecraft:deepslate_diamond_ore"] = 5,
|
|
||||||
["minecraft:deepslate_emerald_ore"] = 5,
|
|
||||||
["minecraft:deepslate_redstone_ore"] = 2,
|
|
||||||
["minecraft:deepslate_lapis_ore"] = 2,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Check for modem
|
-- Check for modem
|
||||||
local modem = peripheral.find("modem")
|
local WebBridge = require('platform.webbridge')
|
||||||
|
local modem, modemSide = WebBridge.findModem(true)
|
||||||
if not modem then
|
if not modem then
|
||||||
error("No wireless modem found!")
|
error("No wireless modem found!")
|
||||||
end
|
end
|
||||||
modem.open(CHANNEL_RECEIVE)
|
-- Open command channel (respects dual-mode migration)
|
||||||
print("Advanced Mining Turtle v4 (Eval Protocol)")
|
WebBridge.openChannels(modem, { 'remoteturtle.command' })
|
||||||
|
|
||||||
|
print("Server-Driven Turtle v5 (Pure Eval Protocol)")
|
||||||
print("ID: " .. os.getComputerID())
|
print("ID: " .. os.getComputerID())
|
||||||
print("Modem opened on channel " .. CHANNEL_RECEIVE)
|
print("Modem opened on channel " .. CHANNEL_RECEIVE)
|
||||||
|
|
||||||
@@ -63,69 +51,6 @@ local function updatePosition()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ========== Movement Functions (with position tracking) ==========
|
|
||||||
|
|
||||||
local function updateFacingAfterTurn(right)
|
|
||||||
if right then
|
|
||||||
state.facing = (state.facing + 1) % 4
|
|
||||||
else
|
|
||||||
state.facing = (state.facing - 1) % 4
|
|
||||||
end
|
|
||||||
_G._turtleFacing = state.facing
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartForward()
|
|
||||||
local success = turtle.forward()
|
|
||||||
if success and state.position then
|
|
||||||
if state.facing == 0 then state.position.z = state.position.z - 1
|
|
||||||
elseif state.facing == 1 then state.position.x = state.position.x + 1
|
|
||||||
elseif state.facing == 2 then state.position.z = state.position.z + 1
|
|
||||||
elseif state.facing == 3 then state.position.x = state.position.x - 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartBack()
|
|
||||||
local success = turtle.back()
|
|
||||||
if success and state.position then
|
|
||||||
if state.facing == 0 then state.position.z = state.position.z + 1
|
|
||||||
elseif state.facing == 1 then state.position.x = state.position.x - 1
|
|
||||||
elseif state.facing == 2 then state.position.z = state.position.z - 1
|
|
||||||
elseif state.facing == 3 then state.position.x = state.position.x + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartUp()
|
|
||||||
local success = turtle.up()
|
|
||||||
if success and state.position then
|
|
||||||
state.position.y = state.position.y + 1
|
|
||||||
end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartDown()
|
|
||||||
local success = turtle.down()
|
|
||||||
if success and state.position then
|
|
||||||
state.position.y = state.position.y - 1
|
|
||||||
end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartTurnRight()
|
|
||||||
local success = turtle.turnRight()
|
|
||||||
if success then updateFacingAfterTurn(true) end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
local function smartTurnLeft()
|
|
||||||
local success = turtle.turnLeft()
|
|
||||||
if success then updateFacingAfterTurn(false) end
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ========== Fuel & Inventory ==========
|
-- ========== Fuel & Inventory ==========
|
||||||
|
|
||||||
local function updateFuel()
|
local function updateFuel()
|
||||||
@@ -133,20 +58,6 @@ local function updateFuel()
|
|||||||
return state.fuel
|
return state.fuel
|
||||||
end
|
end
|
||||||
|
|
||||||
local function tryRefuel()
|
|
||||||
for slot = 1, 16 do
|
|
||||||
turtle.select(slot)
|
|
||||||
if turtle.refuel(0) then
|
|
||||||
local count = turtle.getItemCount()
|
|
||||||
if count > 0 then
|
|
||||||
turtle.refuel(1)
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function updateInventory()
|
local function updateInventory()
|
||||||
state.inventory = {}
|
state.inventory = {}
|
||||||
for slot = 1, 16 do
|
for slot = 1, 16 do
|
||||||
@@ -161,97 +72,27 @@ local function updateInventory()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function inventoryFull()
|
|
||||||
for slot = 1, 16 do
|
|
||||||
if turtle.getItemCount(slot) == 0 then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ========== Block Scanning ==========
|
|
||||||
|
|
||||||
local function scanAllDirections()
|
|
||||||
if not state.position then return {} end
|
|
||||||
|
|
||||||
local scannedBlocks = {}
|
|
||||||
local myID = os.getComputerID()
|
|
||||||
|
|
||||||
local function addBlock(relX, relY, relZ, blockData)
|
|
||||||
if blockData and blockData.name then
|
|
||||||
table.insert(scannedBlocks, {
|
|
||||||
x = state.position.x + relX,
|
|
||||||
y = state.position.y + relY,
|
|
||||||
z = state.position.z + relZ,
|
|
||||||
name = blockData.name,
|
|
||||||
metadata = blockData.metadata or 0,
|
|
||||||
discoveredBy = myID
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local hasBlock, data
|
|
||||||
hasBlock, data = turtle.inspectUp()
|
|
||||||
if hasBlock then addBlock(0, 1, 0, data) end
|
|
||||||
|
|
||||||
hasBlock, data = turtle.inspectDown()
|
|
||||||
if hasBlock then addBlock(0, -1, 0, data) end
|
|
||||||
|
|
||||||
local originalFacing = state.facing
|
|
||||||
for i = 0, 3 do
|
|
||||||
hasBlock, data = turtle.inspect()
|
|
||||||
if hasBlock then
|
|
||||||
local relX, relZ = 0, 0
|
|
||||||
if state.facing == 0 then relZ = -1
|
|
||||||
elseif state.facing == 1 then relX = 1
|
|
||||||
elseif state.facing == 2 then relZ = 1
|
|
||||||
elseif state.facing == 3 then relX = -1
|
|
||||||
end
|
|
||||||
addBlock(relX, 0, relZ, data)
|
|
||||||
end
|
|
||||||
if i < 3 then smartTurnRight() end
|
|
||||||
end
|
|
||||||
|
|
||||||
if state.facing ~= originalFacing then
|
|
||||||
smartTurnRight()
|
|
||||||
end
|
|
||||||
|
|
||||||
return scannedBlocks
|
|
||||||
end
|
|
||||||
|
|
||||||
local function reportDiscoveredBlocks(blocks)
|
|
||||||
if #blocks == 0 then return end
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
|
||||||
type = "blocks_discovered",
|
|
||||||
turtleID = os.getComputerID(),
|
|
||||||
blocks = blocks,
|
|
||||||
timestamp = os.epoch("utc")
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ========== Status Broadcasting ==========
|
-- ========== Status Broadcasting ==========
|
||||||
|
|
||||||
local function broadcastStatus()
|
local function broadcastStatus()
|
||||||
updateFuel()
|
updateFuel()
|
||||||
updateInventory()
|
updateInventory()
|
||||||
|
|
||||||
local surroundings = {}
|
local surroundings = {}
|
||||||
local hasBlock, data
|
local hasBlock, data
|
||||||
|
|
||||||
hasBlock, data = turtle.inspect()
|
hasBlock, data = turtle.inspect()
|
||||||
if hasBlock then surroundings.forward = {name = data.name, metadata = data.metadata or 0} end
|
if hasBlock then surroundings.forward = {name = data.name, metadata = data.metadata or 0} end
|
||||||
|
|
||||||
hasBlock, data = turtle.inspectUp()
|
hasBlock, data = turtle.inspectUp()
|
||||||
if hasBlock then surroundings.up = {name = data.name, metadata = data.metadata or 0} end
|
if hasBlock then surroundings.up = {name = data.name, metadata = data.metadata or 0} end
|
||||||
|
|
||||||
hasBlock, data = turtle.inspectDown()
|
hasBlock, data = turtle.inspectDown()
|
||||||
if hasBlock then surroundings.down = {name = data.name, metadata = data.metadata or 0} end
|
if hasBlock then surroundings.down = {name = data.name, metadata = data.metadata or 0} end
|
||||||
|
|
||||||
local statusPacket = {
|
local statusPacket = {
|
||||||
type = "status",
|
type = "status",
|
||||||
turtleID = os.getComputerID(),
|
turtleID = os.getComputerID(),
|
||||||
mode = state.mode,
|
|
||||||
position = state.position,
|
position = state.position,
|
||||||
homePosition = state.homePosition,
|
homePosition = state.homePosition,
|
||||||
fuel = state.fuel,
|
fuel = state.fuel,
|
||||||
@@ -260,8 +101,9 @@ local function broadcastStatus()
|
|||||||
facing = state.facing,
|
facing = state.facing,
|
||||||
surroundings = surroundings,
|
surroundings = surroundings,
|
||||||
evalSupported = true,
|
evalSupported = true,
|
||||||
|
label = os.getComputerLabel(),
|
||||||
}
|
}
|
||||||
|
|
||||||
modem.transmit(STATUS_CHANNEL, CHANNEL_RECEIVE, statusPacket)
|
modem.transmit(STATUS_CHANNEL, CHANNEL_RECEIVE, statusPacket)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -279,14 +121,24 @@ local function executeEval(uuid, code)
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, result = pcall(fn)
|
local results = table.pack(pcall(fn))
|
||||||
|
local ok = results[1]
|
||||||
|
|
||||||
if ok then
|
if ok then
|
||||||
|
local returnVal
|
||||||
|
if results.n <= 2 then
|
||||||
|
returnVal = results[2]
|
||||||
|
else
|
||||||
|
returnVal = {}
|
||||||
|
for i = 2, results.n do
|
||||||
|
returnVal[i - 1] = results[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
type = "eval_response",
|
type = "eval_response",
|
||||||
uuid = uuid,
|
uuid = uuid,
|
||||||
result = result,
|
result = returnVal,
|
||||||
error = nil,
|
error = nil,
|
||||||
turtleID = os.getComputerID()
|
turtleID = os.getComputerID()
|
||||||
})
|
})
|
||||||
@@ -295,285 +147,40 @@ local function executeEval(uuid, code)
|
|||||||
type = "eval_response",
|
type = "eval_response",
|
||||||
uuid = uuid,
|
uuid = uuid,
|
||||||
result = nil,
|
result = nil,
|
||||||
error = "Runtime error: " .. tostring(result),
|
error = "Runtime error: " .. tostring(results[2]),
|
||||||
turtleID = os.getComputerID()
|
turtleID = os.getComputerID()
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ========== Legacy Command Handling ==========
|
|
||||||
|
|
||||||
local legacyCommands = {
|
|
||||||
forward = function() return smartForward(), "Moved forward" end,
|
|
||||||
back = function() return smartBack(), "Moved back" end,
|
|
||||||
up = function() return smartUp(), "Moved up" end,
|
|
||||||
down = function() return smartDown(), "Moved down" end,
|
|
||||||
turnLeft = function() return smartTurnLeft(), "Turned left" end,
|
|
||||||
turnRight = function() return smartTurnRight(), "Turned right" end,
|
|
||||||
dig = function()
|
|
||||||
local hasBlock, data = turtle.inspect()
|
|
||||||
if hasBlock then
|
|
||||||
turtle.dig()
|
|
||||||
return true, "Dug: " .. (data.name or "block")
|
|
||||||
end
|
|
||||||
return false, "Nothing to dig"
|
|
||||||
end,
|
|
||||||
digUp = function()
|
|
||||||
local hasBlock, data = turtle.inspectUp()
|
|
||||||
if hasBlock then
|
|
||||||
turtle.digUp()
|
|
||||||
return true, "Dug up: " .. (data.name or "block")
|
|
||||||
end
|
|
||||||
return false, "Nothing above"
|
|
||||||
end,
|
|
||||||
digDown = function()
|
|
||||||
local hasBlock, data = turtle.inspectDown()
|
|
||||||
if hasBlock then
|
|
||||||
turtle.digDown()
|
|
||||||
return true, "Dug down: " .. (data.name or "block")
|
|
||||||
end
|
|
||||||
return false, "Nothing below"
|
|
||||||
end,
|
|
||||||
place = function() return turtle.place(), "Placed" end,
|
|
||||||
select = function(slot) return turtle.select(slot), "Selected slot " .. slot end,
|
|
||||||
refuel = function()
|
|
||||||
local success = tryRefuel()
|
|
||||||
updateFuel()
|
|
||||||
broadcastStatus()
|
|
||||||
return success, success and ("Refueled! Now: " .. state.fuel) or "No fuel"
|
|
||||||
end,
|
|
||||||
status = function()
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Status sent"
|
|
||||||
end,
|
|
||||||
setHome = function()
|
|
||||||
if not state.position then
|
|
||||||
if not updatePosition() then
|
|
||||||
return false, "No GPS"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
state.homePosition = {
|
|
||||||
x = state.position.x,
|
|
||||||
y = state.position.y,
|
|
||||||
z = state.position.z
|
|
||||||
}
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
|
||||||
type = "set_home",
|
|
||||||
turtleID = os.getComputerID(),
|
|
||||||
position = state.homePosition
|
|
||||||
})
|
|
||||||
return true, "Home set"
|
|
||||||
end,
|
|
||||||
explore = function()
|
|
||||||
if not state.homePosition then
|
|
||||||
if state.position then
|
|
||||||
state.homePosition = {
|
|
||||||
x = state.position.x,
|
|
||||||
y = state.position.y,
|
|
||||||
z = state.position.z
|
|
||||||
}
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
|
||||||
type = "set_home",
|
|
||||||
turtleID = os.getComputerID(),
|
|
||||||
position = state.homePosition
|
|
||||||
})
|
|
||||||
else
|
|
||||||
return false, "No home or GPS"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
state.mode = "exploring"
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Exploring"
|
|
||||||
end,
|
|
||||||
mine = function()
|
|
||||||
if not state.homePosition then
|
|
||||||
if state.position then
|
|
||||||
state.homePosition = {
|
|
||||||
x = state.position.x,
|
|
||||||
y = state.position.y,
|
|
||||||
z = state.position.z
|
|
||||||
}
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
|
||||||
type = "set_home",
|
|
||||||
turtleID = os.getComputerID(),
|
|
||||||
position = state.homePosition
|
|
||||||
})
|
|
||||||
else
|
|
||||||
return false, "No home or GPS"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
state.mode = "mining"
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Mining"
|
|
||||||
end,
|
|
||||||
returnHome = function()
|
|
||||||
state.mode = "returning"
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Returning home"
|
|
||||||
end,
|
|
||||||
stop = function()
|
|
||||||
state.mode = "idle"
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Stopped"
|
|
||||||
end,
|
|
||||||
manual = function()
|
|
||||||
state.mode = "manual"
|
|
||||||
broadcastStatus()
|
|
||||||
return true, "Manual control"
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
|
|
||||||
local function processLegacyCommand(message)
|
|
||||||
if type(message) ~= "table" then return end
|
|
||||||
local myID = os.getComputerID()
|
|
||||||
if message.target and message.target ~= myID then return end
|
|
||||||
|
|
||||||
if message.command then
|
|
||||||
local handler = legacyCommands[message.command]
|
|
||||||
if handler then
|
|
||||||
local success, result = handler(message.param)
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
|
||||||
status = success and "ok" or "error",
|
|
||||||
message = result or "",
|
|
||||||
turtleID = myID
|
|
||||||
})
|
|
||||||
broadcastStatus()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ========== Message Processing ==========
|
-- ========== Message Processing ==========
|
||||||
|
|
||||||
local function processMessage(channel, message)
|
local function processMessage(channel, message)
|
||||||
if type(message) ~= "table" then return end
|
if type(message) ~= "table" then return end
|
||||||
local myID = os.getComputerID()
|
local myID = os.getComputerID()
|
||||||
if message.target and message.target ~= myID then return end
|
if message.target and message.target ~= myID then return end
|
||||||
|
|
||||||
if message.type == "eval" and message.uuid and message.code then
|
if message.type == "eval" and message.uuid and message.code then
|
||||||
print("Eval: " .. message.uuid:sub(1, 8) .. "...")
|
print("Eval: " .. message.uuid:sub(1, 8) .. "...")
|
||||||
executeEval(message.uuid, message.code)
|
executeEval(message.uuid, message.code)
|
||||||
if message.stateUpdate then
|
|
||||||
state.mode = message.stateUpdate
|
|
||||||
end
|
|
||||||
|
|
||||||
elseif message.type == "state_change" and message.state then
|
|
||||||
state.mode = message.state
|
|
||||||
broadcastStatus()
|
|
||||||
|
|
||||||
elseif message.type == "home_position" and message.turtleID == myID then
|
elseif message.type == "home_position" and message.turtleID == myID then
|
||||||
if message.homePosition then
|
if message.homePosition then
|
||||||
state.homePosition = message.homePosition
|
state.homePosition = message.homePosition
|
||||||
print("Home synced from server")
|
print("Home synced from server")
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif message.type == "home_set_confirm" and message.turtleID == myID then
|
elseif message.type == "home_set_confirm" and message.turtleID == myID then
|
||||||
if message.homePosition then
|
if message.homePosition then
|
||||||
state.homePosition = message.homePosition
|
state.homePosition = message.homePosition
|
||||||
print("Home confirmed by server")
|
print("Home confirmed by server")
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif message.command then
|
|
||||||
processLegacyCommand(message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ========== Local Autonomous Behavior (fallback) ==========
|
elseif message.type == "rename" and message.name then
|
||||||
|
os.setComputerLabel(message.name)
|
||||||
local localStuckCounter = 0
|
print("Renamed to: " .. message.name)
|
||||||
|
|
||||||
local function getDistance(pos1, pos2)
|
|
||||||
return math.abs(pos1.x - pos2.x) + math.abs(pos1.y - pos2.y) + math.abs(pos1.z - pos2.z)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function localExploreStep()
|
|
||||||
if state.position then
|
|
||||||
local blocks = scanAllDirections()
|
|
||||||
if #blocks > 0 then
|
|
||||||
reportDiscoveredBlocks(blocks)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Mine valuable ores
|
|
||||||
for _, direction in ipairs({"forward", "up", "down"}) do
|
|
||||||
local inspectFn = direction == "forward" and turtle.inspect
|
|
||||||
or direction == "up" and turtle.inspectUp
|
|
||||||
or turtle.inspectDown
|
|
||||||
local digFn = direction == "forward" and turtle.dig
|
|
||||||
or direction == "up" and turtle.digUp
|
|
||||||
or turtle.digDown
|
|
||||||
|
|
||||||
local hasBlock, data = inspectFn()
|
|
||||||
if hasBlock and config.valuableBlocks[data.name] then
|
|
||||||
digFn()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local r = math.random(1, 100)
|
|
||||||
if r < 75 then
|
|
||||||
local hasBlock = turtle.inspect()
|
|
||||||
if hasBlock then turtle.dig() end
|
|
||||||
if not smartForward() then
|
|
||||||
smartTurnRight()
|
|
||||||
localStuckCounter = localStuckCounter + 1
|
|
||||||
else
|
|
||||||
localStuckCounter = 0
|
|
||||||
end
|
|
||||||
elseif r < 90 and state.position and state.position.y > 15 then
|
|
||||||
local hasBlock = turtle.inspectDown()
|
|
||||||
if hasBlock then turtle.digDown() end
|
|
||||||
smartDown()
|
|
||||||
else
|
|
||||||
local hasBlock = turtle.inspectUp()
|
|
||||||
if hasBlock then turtle.digUp() end
|
|
||||||
smartUp()
|
|
||||||
end
|
|
||||||
|
|
||||||
if localStuckCounter > 5 then
|
|
||||||
turtle.digUp()
|
|
||||||
smartUp()
|
|
||||||
localStuckCounter = 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function localReturnHomeStep()
|
|
||||||
if not state.homePosition or not state.position then
|
|
||||||
state.mode = "idle"
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
if getDistance(state.position, state.homePosition) <= 1 then
|
|
||||||
state.mode = "idle"
|
|
||||||
broadcastStatus()
|
broadcastStatus()
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local dx = state.homePosition.x - state.position.x
|
|
||||||
local dy = state.homePosition.y - state.position.y
|
|
||||||
local dz = state.homePosition.z - state.position.z
|
|
||||||
|
|
||||||
if math.abs(dy) > 3 then
|
|
||||||
if dy > 0 then
|
|
||||||
turtle.digUp(); smartUp()
|
|
||||||
else
|
|
||||||
turtle.digDown(); smartDown()
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local targetFacing
|
|
||||||
if math.abs(dx) > math.abs(dz) then
|
|
||||||
targetFacing = dx > 0 and 1 or 3
|
|
||||||
else
|
|
||||||
targetFacing = dz > 0 and 2 or 0
|
|
||||||
end
|
|
||||||
while state.facing ~= targetFacing do
|
|
||||||
smartTurnRight()
|
|
||||||
end
|
|
||||||
turtle.dig()
|
|
||||||
if not smartForward() then
|
|
||||||
smartTurnRight()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ========== Sync Home ==========
|
-- ========== Sync Home ==========
|
||||||
@@ -600,14 +207,222 @@ end
|
|||||||
|
|
||||||
syncHomeWithServer()
|
syncHomeWithServer()
|
||||||
updateFuel()
|
updateFuel()
|
||||||
print("Ready! Turtle " .. os.getComputerID() .. " online (v4 eval protocol)")
|
|
||||||
|
-- ========== Movement Wrapping (heading + position tracking) ==========
|
||||||
|
|
||||||
|
-- Heading: 0=south(+z), 1=west(-x), 2=north(-z), 3=east(+x)
|
||||||
|
local MOVE_DELTA = {
|
||||||
|
[0] = { x = 0, z = 1 }, -- south
|
||||||
|
[1] = { x = -1, z = 0 }, -- west
|
||||||
|
[2] = { x = 0, z = -1 }, -- north
|
||||||
|
[3] = { x = 1, z = 0 }, -- east
|
||||||
|
}
|
||||||
|
|
||||||
|
local _rawForward = turtle.forward
|
||||||
|
local _rawBack = turtle.back
|
||||||
|
local _rawUp = turtle.up
|
||||||
|
local _rawDown = turtle.down
|
||||||
|
local _rawTurnLeft = turtle.turnLeft
|
||||||
|
local _rawTurnRight = turtle.turnRight
|
||||||
|
|
||||||
|
turtle.forward = function()
|
||||||
|
local ok, reason = _rawForward()
|
||||||
|
if ok and state.position then
|
||||||
|
local d = MOVE_DELTA[state.facing]
|
||||||
|
state.position.x = state.position.x + d.x
|
||||||
|
state.position.z = state.position.z + d.z
|
||||||
|
end
|
||||||
|
return ok, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
turtle.back = function()
|
||||||
|
local ok, reason = _rawBack()
|
||||||
|
if ok and state.position then
|
||||||
|
local d = MOVE_DELTA[state.facing]
|
||||||
|
state.position.x = state.position.x - d.x
|
||||||
|
state.position.z = state.position.z - d.z
|
||||||
|
end
|
||||||
|
return ok, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
turtle.up = function()
|
||||||
|
local ok, reason = _rawUp()
|
||||||
|
if ok and state.position then
|
||||||
|
state.position.y = state.position.y + 1
|
||||||
|
end
|
||||||
|
return ok, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
turtle.down = function()
|
||||||
|
local ok, reason = _rawDown()
|
||||||
|
if ok and state.position then
|
||||||
|
state.position.y = state.position.y - 1
|
||||||
|
end
|
||||||
|
return ok, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
turtle.turnLeft = function()
|
||||||
|
local ok = _rawTurnLeft()
|
||||||
|
if ok then
|
||||||
|
state.facing = (state.facing + 3) % 4
|
||||||
|
_G._turtleFacing = state.facing
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
turtle.turnRight = function()
|
||||||
|
local ok = _rawTurnRight()
|
||||||
|
if ok then
|
||||||
|
state.facing = (state.facing + 1) % 4
|
||||||
|
_G._turtleFacing = state.facing
|
||||||
|
end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Detect heading via GPS triangulation
|
||||||
|
local function detectHeading()
|
||||||
|
local x1, _, z1 = gps.locate(2)
|
||||||
|
if not x1 then return false end
|
||||||
|
if _rawForward() then
|
||||||
|
local x2, _, z2 = gps.locate(2)
|
||||||
|
_rawBack()
|
||||||
|
if x2 then
|
||||||
|
local dx = math.floor(x2) - math.floor(x1)
|
||||||
|
local dz = math.floor(z2) - math.floor(z1)
|
||||||
|
if dz > 0 then state.facing = 0 -- south
|
||||||
|
elseif dz < 0 then state.facing = 2 -- north
|
||||||
|
elseif dx > 0 then state.facing = 3 -- east
|
||||||
|
elseif dx < 0 then state.facing = 1 -- west
|
||||||
|
end
|
||||||
|
_G._turtleFacing = state.facing
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.position then
|
||||||
|
if detectHeading() then
|
||||||
|
print("Heading: " .. ({"south","west","north","east"})[state.facing + 1])
|
||||||
|
else
|
||||||
|
print("Heading: unknown (blocked)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ========== Pathfinding Module ==========
|
||||||
|
|
||||||
|
local pathfind = {}
|
||||||
|
|
||||||
|
--- Face a target heading.
|
||||||
|
function pathfind.face(targetH)
|
||||||
|
targetH = targetH % 4
|
||||||
|
while state.facing ~= targetH do
|
||||||
|
local diff = (targetH - state.facing) % 4
|
||||||
|
if diff == 1 then
|
||||||
|
turtle.turnRight()
|
||||||
|
elseif diff == 3 then
|
||||||
|
turtle.turnLeft()
|
||||||
|
else
|
||||||
|
turtle.turnRight()
|
||||||
|
turtle.turnRight()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Navigate to target coordinates with simple obstacle avoidance.
|
||||||
|
-- @param tx, ty, tz target position
|
||||||
|
-- @param options { dig = false, maxAttempts = 256 }
|
||||||
|
-- @return true or false, error
|
||||||
|
function pathfind.goto(tx, ty, tz, options)
|
||||||
|
options = options or {}
|
||||||
|
local maxAttempts = options.maxAttempts or 256
|
||||||
|
local dig = options.dig or false
|
||||||
|
local attempts = 0
|
||||||
|
|
||||||
|
while attempts < maxAttempts do
|
||||||
|
local pos = state.position
|
||||||
|
if not pos then return false, "No GPS position" end
|
||||||
|
|
||||||
|
-- Arrived?
|
||||||
|
if pos.x == tx and pos.y == ty and pos.z == tz then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
attempts = attempts + 1
|
||||||
|
local moved = false
|
||||||
|
|
||||||
|
-- Priority: Y first (get to correct height), then X, then Z
|
||||||
|
if not moved and pos.y ~= ty then
|
||||||
|
if pos.y < ty then
|
||||||
|
moved = turtle.up()
|
||||||
|
if not moved and dig then turtle.digUp(); moved = turtle.up() end
|
||||||
|
else
|
||||||
|
moved = turtle.down()
|
||||||
|
if not moved and dig then turtle.digDown(); moved = turtle.down() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not moved and pos.x ~= tx then
|
||||||
|
pathfind.face(tx > pos.x and 3 or 1)
|
||||||
|
moved = turtle.forward()
|
||||||
|
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not moved and pos.z ~= tz then
|
||||||
|
pathfind.face(tz > pos.z and 0 or 2)
|
||||||
|
moved = turtle.forward()
|
||||||
|
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Obstacle avoidance: try going around
|
||||||
|
if not moved then
|
||||||
|
if turtle.up() then
|
||||||
|
moved = true
|
||||||
|
elseif turtle.down() then
|
||||||
|
moved = true
|
||||||
|
else
|
||||||
|
turtle.turnRight()
|
||||||
|
if turtle.forward() then
|
||||||
|
moved = true
|
||||||
|
else
|
||||||
|
turtle.turnLeft()
|
||||||
|
turtle.turnLeft()
|
||||||
|
if turtle.forward() then
|
||||||
|
moved = true
|
||||||
|
else
|
||||||
|
turtle.turnRight() -- restore heading
|
||||||
|
return false, "Stuck at " .. pos.x .. "," .. pos.y .. "," .. pos.z
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false, "Max attempts exceeded"
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Go home (to saved home position).
|
||||||
|
function pathfind.goHome(options)
|
||||||
|
if not state.homePosition then return false, "No home position set" end
|
||||||
|
return pathfind.goto(state.homePosition.x, state.homePosition.y, state.homePosition.z, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get current heading name.
|
||||||
|
function pathfind.headingName()
|
||||||
|
return ({"south","west","north","east"})[state.facing + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Expose globally for eval access
|
||||||
|
_G._pathfind = pathfind
|
||||||
|
|
||||||
|
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven + pathfinding)")
|
||||||
broadcastStatus()
|
broadcastStatus()
|
||||||
|
|
||||||
-- ========== Main Loop ==========
|
-- ========== Main Loop ==========
|
||||||
|
|
||||||
parallel.waitForAny(
|
parallel.waitForAny(
|
||||||
function()
|
function()
|
||||||
-- GPS retry
|
-- GPS retry loop
|
||||||
while true do
|
while true do
|
||||||
if not state.position then
|
if not state.position then
|
||||||
sleep(10)
|
sleep(10)
|
||||||
@@ -620,41 +435,43 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
function()
|
function()
|
||||||
-- Status broadcast
|
-- Status broadcast loop
|
||||||
while true do
|
while true do
|
||||||
sleep(config.statusUpdateInterval)
|
sleep(config.statusUpdateInterval)
|
||||||
broadcastStatus()
|
broadcastStatus()
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
function()
|
function()
|
||||||
-- Command processing
|
-- Command processing (eval protocol)
|
||||||
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (100) and target (4210) channels during migration.
|
||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
if channel == CHANNEL_RECEIVE then
|
if Channels.match('remoteturtle.command', channel) then
|
||||||
processMessage(channel, message)
|
processMessage(channel, message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
function()
|
function()
|
||||||
-- Inventory change events (real-time)
|
-- Inventory change events (real-time)
|
||||||
while true do
|
while true do
|
||||||
os.pullEvent("turtle_inventory")
|
os.pullEvent("turtle_inventory")
|
||||||
local inventory = {}
|
local inventory = {}
|
||||||
local fns = {}
|
|
||||||
for i = 1, 16 do
|
for i = 1, 16 do
|
||||||
fns[i] = function()
|
local item = turtle.getItemDetail(i)
|
||||||
local item = turtle.getItemDetail(i, true)
|
if item then
|
||||||
if item then
|
table.insert(inventory, {
|
||||||
inventory[tostring(i)] = item
|
slot = i,
|
||||||
end
|
name = item.name,
|
||||||
|
count = item.count
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
parallel.waitForAll(table.unpack(fns))
|
|
||||||
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
type = "inventory_update",
|
type = "inventory_update",
|
||||||
turtleID = os.getComputerID(),
|
turtleID = os.getComputerID(),
|
||||||
@@ -663,7 +480,7 @@ parallel.waitForAny(
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
function()
|
function()
|
||||||
-- Peripheral attached events (real-time)
|
-- Peripheral attached events (real-time)
|
||||||
while true do
|
while true do
|
||||||
@@ -674,7 +491,7 @@ parallel.waitForAny(
|
|||||||
for _, name in ipairs(names) do
|
for _, name in ipairs(names) do
|
||||||
peripherals[name] = {types = {peripheral.getType(name)}}
|
peripherals[name] = {types = {peripheral.getType(name)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
type = "peripheral_attached",
|
type = "peripheral_attached",
|
||||||
turtleID = os.getComputerID(),
|
turtleID = os.getComputerID(),
|
||||||
@@ -683,7 +500,7 @@ parallel.waitForAny(
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
function()
|
function()
|
||||||
-- Peripheral detached events (real-time)
|
-- Peripheral detached events (real-time)
|
||||||
while true do
|
while true do
|
||||||
@@ -697,43 +514,5 @@ parallel.waitForAny(
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end,
|
|
||||||
|
|
||||||
function()
|
|
||||||
-- Local autonomous fallback
|
|
||||||
while true do
|
|
||||||
if state.mode == "exploring" or state.mode == "mining" then
|
|
||||||
updateFuel()
|
|
||||||
|
|
||||||
if state.position and state.homePosition then
|
|
||||||
local dist = getDistance(state.position, state.homePosition)
|
|
||||||
if dist > 200 then
|
|
||||||
state.mode = "returning"
|
|
||||||
broadcastStatus()
|
|
||||||
elseif state.fuel ~= "unlimited" and state.fuel < 500 then
|
|
||||||
if not tryRefuel() then
|
|
||||||
state.mode = "returning"
|
|
||||||
broadcastStatus()
|
|
||||||
end
|
|
||||||
elseif inventoryFull() then
|
|
||||||
state.mode = "returning"
|
|
||||||
broadcastStatus()
|
|
||||||
else
|
|
||||||
localExploreStep()
|
|
||||||
sleep(0.2)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
localExploreStep()
|
|
||||||
sleep(0.2)
|
|
||||||
end
|
|
||||||
|
|
||||||
elseif state.mode == "returning" then
|
|
||||||
local done = localReturnHomeStep()
|
|
||||||
if not done then sleep(0.1) end
|
|
||||||
|
|
||||||
else
|
|
||||||
sleep(0.5)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|||||||
1045
webbridge.lua
1045
webbridge.lua
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user