Compare commits
80 Commits
cef3cdf03d
...
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 |
34
.package
Normal file
34
.package
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
required = {
|
||||
'platform',
|
||||
},
|
||||
title = "RemoteTurtle",
|
||||
description = "Web-based remote control for CC:Tweaked turtles with 3D visualization, D* Lite pathfinding, and state-machine AI. Includes turtle controller, GPS host, web bridge, and pocket computer programs.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/remoteturtle/master/",
|
||||
exclude = {
|
||||
"^server/", "^client/", "^__tests__/",
|
||||
"^startup_", "^start%.",
|
||||
"%.md$", "%.yml$", "%.json$", "%.bat$", "%.sh$",
|
||||
"^Dockerfile", "^%.git", "^LICENSE$", "^node_modules/",
|
||||
},
|
||||
install = [[
|
||||
local pkgDir = fs.combine("packages", "remoteturtle")
|
||||
|
||||
-- Web Bridge config
|
||||
print("")
|
||||
print("-- RemoteTurtle Web Bridge Setup --")
|
||||
print("")
|
||||
write("Server URL (e.g. http://192.168.1.10:4200): ")
|
||||
local serverUrl = read()
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local wsUrl = serverUrl:gsub("^http", "ws") .. "/ws/bridge"
|
||||
local cfg = textutils.serialiseJSON({ serverUrl = serverUrl, wsUrl = wsUrl })
|
||||
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
else
|
||||
print("Skipped — edit .webbridge_config later.")
|
||||
end
|
||||
]],
|
||||
}
|
||||
@@ -64,6 +64,36 @@ You should see:
|
||||
**GPS not working?**
|
||||
- Set up 4 GPS host computers at high altitude
|
||||
- Run `gps host X Y Z` on each (with their coordinates)
|
||||
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||
|
||||
## Pathfinding
|
||||
|
||||
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||
You can trigger it from the web UI via eval commands:
|
||||
|
||||
```lua
|
||||
-- Navigate to coordinates (avoids obstacles)
|
||||
_pathfind.goto(100, 65, -200)
|
||||
|
||||
-- Navigate with block digging enabled
|
||||
_pathfind.goto(100, 65, -200, { dig = true })
|
||||
|
||||
-- Go home
|
||||
_pathfind.goHome()
|
||||
|
||||
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||
_pathfind.face(3)
|
||||
|
||||
-- Get current heading name
|
||||
_pathfind.headingName() -- "east"
|
||||
```
|
||||
|
||||
The pathfinder:
|
||||
- Uses GPS for initial position, then tracks movement locally
|
||||
- Auto-detects heading on startup via GPS triangulation
|
||||
- Handles obstacles by trying to go over/around them
|
||||
- Supports optional block digging for clearing paths
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -76,6 +76,31 @@
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* === Cross-link Button === */
|
||||
.cross-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: #2e4a8b;
|
||||
color: #55ffff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cross-link-btn:hover {
|
||||
background: #3e5a9b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -16,6 +16,8 @@ function App() {
|
||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
|
||||
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
@@ -52,6 +54,15 @@ function App() {
|
||||
</div>
|
||||
<div className="panel-container">
|
||||
<div className="panel-tabs">
|
||||
<a
|
||||
href={inventoryDashboardUrl}
|
||||
className="cross-link-btn"
|
||||
title="Open Inventory Manager Dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
📦 Inventory
|
||||
</a>
|
||||
<button
|
||||
className={panelTab === 'control' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('control')}
|
||||
|
||||
@@ -5,7 +5,9 @@ import './ControlPanel.css';
|
||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||
const inventoryCount = turtle.inventory?.length || 0;
|
||||
const inventoryCount = Array.isArray(turtle.inventory)
|
||||
? turtle.inventory.length
|
||||
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||
|
||||
const modeColors = {
|
||||
@@ -196,6 +198,14 @@ function TurtleDetails({ turtle }) {
|
||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||
</span>
|
||||
</div>
|
||||
{turtle.totalSteps > 0 && (
|
||||
<div className="status-item">
|
||||
<span className="label">Steps:</span>
|
||||
<span className="value">
|
||||
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Position:</span>
|
||||
<span className="value">
|
||||
|
||||
@@ -858,8 +858,9 @@ function MiningArea({ area, turtle, isSelected, onClick }) {
|
||||
const centerY = (area.startY + area.endY) / 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 = () => {
|
||||
if (area.color) return area.color;
|
||||
if (area.status === 'completed') return '#10b981'; // green
|
||||
if (area.status === 'mining') return '#f59e0b'; // orange
|
||||
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
|
||||
@@ -977,7 +978,7 @@ function PlayerMarker({ player }) {
|
||||
outlineWidth={0.05}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
Player {player.playerID}
|
||||
{player.label || `Player ${player.playerID}`}
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
@@ -1296,9 +1297,11 @@ function Scene({ interactionMode, onInteraction }) {
|
||||
))}
|
||||
|
||||
{/* Players */}
|
||||
{players.map((player) => (
|
||||
<PlayerMarker key={player.playerID} player={player} />
|
||||
))}
|
||||
{players
|
||||
.filter((p) => p.position && Date.now() - (p.timestamp || 0) < 30000)
|
||||
.map((player) => (
|
||||
<PlayerMarker key={player.playerID} player={player} />
|
||||
))}
|
||||
|
||||
{/* Camera controls */}
|
||||
<OrbitControls
|
||||
|
||||
@@ -446,3 +446,62 @@
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Color picker row */
|
||||
.color-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.color-swatch.active {
|
||||
border-color: #ffff55;
|
||||
box-shadow: 0 0 4px #ffff55;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Color dot in area card */
|
||||
.area-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.area-color-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newArea, setNewArea] = useState({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
@@ -66,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
endX: Number(newArea.endX),
|
||||
endY: Number(newArea.endY),
|
||||
endZ: Number(newArea.endZ),
|
||||
color: newArea.color,
|
||||
status: 'planned'
|
||||
})
|
||||
});
|
||||
@@ -74,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
setShowCreateForm(false);
|
||||
setNewArea({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
@@ -259,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Area Color:</label>
|
||||
<div className="color-picker-row">
|
||||
<input
|
||||
type="color"
|
||||
value={newArea.color}
|
||||
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
|
||||
className="color-input"
|
||||
/>
|
||||
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewArea({ ...newArea, color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Assign Turtle:</label>
|
||||
<select
|
||||
@@ -364,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
return (
|
||||
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||
<div className="area-header">
|
||||
<h3>{area.areaName}</h3>
|
||||
<div className="area-title-row">
|
||||
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
|
||||
<h3>{area.areaName}</h3>
|
||||
</div>
|
||||
<StatusBadge status={area.status} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
|
||||
data.turtles.forEach(turtle => {
|
||||
turtlesMap[turtle.turtleID] = turtle;
|
||||
});
|
||||
const playersMap = {};
|
||||
if (data.players && Array.isArray(data.players)) {
|
||||
data.players.forEach(player => {
|
||||
playersMap[player.playerID] = player;
|
||||
});
|
||||
}
|
||||
set({
|
||||
turtles: turtlesMap,
|
||||
players: playersMap,
|
||||
worldBlocks: data.blocks || []
|
||||
});
|
||||
} else if (data.type === 'turtle_update') {
|
||||
@@ -74,7 +81,8 @@ export const useTurtleStore = create((set, get) => ({
|
||||
[data.playerID]: {
|
||||
playerID: data.playerID,
|
||||
position: data.position,
|
||||
timestamp: data.timestamp
|
||||
label: data.label || null,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -6,12 +6,12 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: turtle-server
|
||||
ports:
|
||||
- "4200:3001" # HTTP API
|
||||
- "3002:3002" # WebSocket
|
||||
- "4200:3001" # HTTP API + WebSocket (unified)
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- WS_PORT=3002
|
||||
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
|
||||
- API_KEY=${API_KEY:-}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- turtle-network
|
||||
|
||||
36
etc/apps.db
Normal file
36
etc/apps.db
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
[ "rt_turtle_controller" ] = {
|
||||
title = "Turtle Controller",
|
||||
category = "RemoteTurtle",
|
||||
run = "turtle.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "rt_gps_host" ] = {
|
||||
title = "GPS Host",
|
||||
category = "RemoteTurtle",
|
||||
run = "gpshost.lua",
|
||||
},
|
||||
[ "rt_web_bridge" ] = {
|
||||
title = "Web Bridge",
|
||||
category = "RemoteTurtle",
|
||||
run = "webbridge.lua",
|
||||
},
|
||||
[ "rt_pocket_control" ] = {
|
||||
title = "Pocket Control",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketcontrol.lua",
|
||||
requires = "pocket",
|
||||
},
|
||||
[ "rt_pocket_remote" ] = {
|
||||
title = "Pocket Remote",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketremote.lua",
|
||||
requires = "pocket",
|
||||
},
|
||||
[ "rt_pocket_gps" ] = {
|
||||
title = "Pocket GPS",
|
||||
category = "RemoteTurtle",
|
||||
run = "pocketgps.lua",
|
||||
requires = "pocket",
|
||||
},
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||
|
||||
-- Find modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -19,9 +21,12 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
modem.open(POCKET_CHANNEL)
|
||||
local WebBridge = require('platform.webbridge')
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
@@ -98,10 +103,10 @@ local function updateMyPosition()
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "player_position",
|
||||
playerID = os.getComputerID(),
|
||||
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
|
||||
position = myPosition,
|
||||
timestamp = os.epoch("utc")
|
||||
})
|
||||
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
|
||||
return true
|
||||
else
|
||||
addLog("GPS: Failed to locate", colors.red)
|
||||
@@ -556,7 +561,7 @@ parallel.waitForAny(
|
||||
function()
|
||||
-- GPS update loop
|
||||
while true do
|
||||
sleep(5)
|
||||
sleep(2)
|
||||
updateMyPosition()
|
||||
end
|
||||
end,
|
||||
@@ -574,7 +579,9 @@ parallel.waitForAny(
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
if message.type == "status" then
|
||||
-- Update turtle list
|
||||
local found = false
|
||||
@@ -590,7 +597,7 @@ parallel.waitForAny(
|
||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||
end
|
||||
end
|
||||
elseif channel == POCKET_CHANNEL and type(message) == "table" then
|
||||
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||
-- Handle responses from webbridge
|
||||
if message.type == "webbridge_status" then
|
||||
webbridgeStatus = message.data
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
-- Live GPS Tracker for Pocket Computer
|
||||
-- Shows your current location in real-time
|
||||
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- Setup modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -15,7 +18,7 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||
|
||||
local w, h = term.getSize()
|
||||
local myID = os.getComputerID()
|
||||
@@ -228,7 +231,9 @@ local function main()
|
||||
local replyChannel = param3
|
||||
local message = param4
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
handleStatus(message)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||
-- Monitor and control autonomous mining turtles
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
@@ -15,8 +18,10 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
@@ -626,10 +631,12 @@ parallel.waitForAny(
|
||||
end,
|
||||
function()
|
||||
-- Status receiver
|
||||
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||
-- Update or add turtle
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
@@ -655,7 +662,7 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
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
|
||||
if message.type == "state_changed" and message.turtleID then
|
||||
for i, t in ipairs(turtles) do
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
# Node.js backend
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
# Development Dockerfile with hot reload
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Development with hot reload
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Install nodemon for hot reload
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm install
|
||||
|
||||
|
||||
387
server/TaskDispatcher.js
Normal file
387
server/TaskDispatcher.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* TaskDispatcher — Automatic task queue dispatcher for RemoteTurtle
|
||||
*
|
||||
* Periodically polls the task_queue for pending tasks, matches them to
|
||||
* available (idle + connected) turtles, maps task_type to state machine
|
||||
* states, and drives the turtle state machine. Handles turtle disconnection
|
||||
* mid-task (re-queues) and completion callbacks.
|
||||
*
|
||||
* Task type → State machine mapping:
|
||||
* mine_area → mining (requires bounds in task_data)
|
||||
* explore → exploring (requires target/area in task_data)
|
||||
* gather → extracting (requires blockName in task_data)
|
||||
* build → building (requires plan in task_data)
|
||||
* transport → moving (requires target position)
|
||||
* clear_area → mining (same as mine_area)
|
||||
* scan → scanning (requires area/range)
|
||||
* farm → farming (requires area)
|
||||
* autocraft → autocrafting (requires recipe)
|
||||
*/
|
||||
|
||||
// Map task_type values from the TaskPanel UI to turtle state machine state names + data mappers
|
||||
const TASK_TYPE_MAP = {
|
||||
mine_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||
explore: { state: 'exploring', mapData: d => ({ target: d.target || d, ...d }) },
|
||||
gather: { state: 'extracting', mapData: d => ({ blockName: d.blockName, count: d.count, ...d }) },
|
||||
build: { state: 'building', mapData: d => ({ plan: d.plan, origin: d.origin, ...d }) },
|
||||
transport: { state: 'moving', mapData: d => ({ target: d.target || d.destination || d, ...d }) },
|
||||
clear_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||
scan: { state: 'scanning', mapData: d => ({ area: d.area, ...d }) },
|
||||
farm: { state: 'farming', mapData: d => ({ area: d.area, ...d }) },
|
||||
autocraft: { state: 'autocrafting', mapData: d => ({ recipe: d.recipe, count: d.count, ...d }) },
|
||||
};
|
||||
|
||||
export class TaskDispatcher {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {Map<number, import('./Turtle.js').Turtle>} opts.turtles - Live turtle map
|
||||
* @param {Object} opts.db - Database module (server/database.js)
|
||||
* @param {Function} opts.broadcastToClients - WebSocket broadcaster
|
||||
* @param {number} [opts.pollInterval=5000] - ms between dispatch cycles
|
||||
*/
|
||||
constructor({ turtles, db, broadcastToClients, pollInterval = 5000 }) {
|
||||
this._turtles = turtles;
|
||||
this._db = db;
|
||||
this._broadcast = broadcastToClients;
|
||||
this._pollInterval = pollInterval;
|
||||
this._timer = null;
|
||||
this._enabled = true;
|
||||
|
||||
// Track which tasks are actively being executed by which turtles
|
||||
// turtleId -> { taskId, taskType }
|
||||
this._activeTasks = new Map();
|
||||
|
||||
// Reverse lookup: taskId -> turtleId
|
||||
this._taskToTurtle = new Map();
|
||||
}
|
||||
|
||||
/** Start the dispatch loop */
|
||||
start() {
|
||||
if (this._timer) return;
|
||||
console.log(`🚀 TaskDispatcher started (poll every ${this._pollInterval}ms)`);
|
||||
this._timer = setInterval(() => this._tick(), this._pollInterval);
|
||||
// Run immediately on start
|
||||
this._tick();
|
||||
}
|
||||
|
||||
/** Stop the dispatch loop */
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
console.log('🛑 TaskDispatcher stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable/disable automatic dispatching (tasks still tracked when disabled) */
|
||||
set enabled(val) {
|
||||
this._enabled = !!val;
|
||||
console.log(`TaskDispatcher: auto-dispatch ${this._enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/** Get dispatcher status for the API */
|
||||
status() {
|
||||
return {
|
||||
enabled: this._enabled,
|
||||
activeTasks: Array.from(this._activeTasks.entries()).map(([turtleId, info]) => ({
|
||||
turtleId,
|
||||
...info,
|
||||
})),
|
||||
pollInterval: this._pollInterval,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Internal ==========
|
||||
|
||||
/**
|
||||
* One dispatch cycle:
|
||||
* 1. Reconcile active tasks (detect turtle disconnects, state completions)
|
||||
* 2. If enabled, find pending tasks and assign to idle turtles
|
||||
*/
|
||||
_tick() {
|
||||
try {
|
||||
this._reconcile();
|
||||
if (this._enabled) {
|
||||
this._dispatch();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TaskDispatcher] tick error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile: check if turtles executing tasks have finished or disconnected.
|
||||
*/
|
||||
_reconcile() {
|
||||
for (const [turtleId, info] of this._activeTasks) {
|
||||
const turtle = this._turtles.get(turtleId);
|
||||
|
||||
// Turtle removed from server
|
||||
if (!turtle) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, 'Turtle no longer exists');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle disconnected mid-task
|
||||
if (!turtle.connected) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, 'Turtle disconnected');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle transitioned to idle → task completed
|
||||
if (turtle.stateName === 'idle') {
|
||||
this._handleTaskCompletion(info.taskId, turtleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle errored out
|
||||
if (turtle._error) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, turtle._error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch: find pending (unassigned or assigned-but-not-started) tasks,
|
||||
* match to available turtles, and start them.
|
||||
*/
|
||||
_dispatch() {
|
||||
// Get all pending tasks (sorted by priority DESC, created_at ASC via DB)
|
||||
let pendingTasks;
|
||||
try {
|
||||
pendingTasks = this._db.getAllTasks('pending');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingTasks || pendingTasks.length === 0) return;
|
||||
|
||||
// Find idle, connected turtles not already executing a task
|
||||
const availableTurtles = this._getAvailableTurtles();
|
||||
if (availableTurtles.length === 0) return;
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
if (availableTurtles.length === 0) break;
|
||||
|
||||
const mapping = TASK_TYPE_MAP[task.task_type];
|
||||
if (!mapping) {
|
||||
console.warn(`[TaskDispatcher] Unknown task type: ${task.task_type} (task #${task.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If task has a specific turtle assignment, respect it
|
||||
let turtle = null;
|
||||
if (task.assigned_turtle_id) {
|
||||
turtle = this._turtles.get(task.assigned_turtle_id);
|
||||
if (!turtle || !turtle.connected || turtle.stateName !== 'idle') {
|
||||
// Assigned turtle not available — skip this task for now
|
||||
continue;
|
||||
}
|
||||
// Remove from available pool
|
||||
const idx = availableTurtles.indexOf(turtle);
|
||||
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||
} else {
|
||||
// Pick the best available turtle (closest to target if we have coords, else first)
|
||||
turtle = this._pickBestTurtle(availableTurtles, task.task_data);
|
||||
const idx = availableTurtles.indexOf(turtle);
|
||||
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (!turtle) continue;
|
||||
|
||||
// Dispatch!
|
||||
this._startTask(task, turtle, mapping);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a task on a turtle.
|
||||
*/
|
||||
_startTask(task, turtle, mapping) {
|
||||
const taskId = task.id;
|
||||
const turtleId = turtle.id;
|
||||
|
||||
console.log(`[TaskDispatcher] Assigning task #${taskId} (${task.task_type}) → Turtle #${turtleId}`);
|
||||
|
||||
// Map task_data to state data
|
||||
const stateData = mapping.mapData(task.task_data || {});
|
||||
|
||||
// Update DB: assign + set in_progress
|
||||
try {
|
||||
this._db.assignTask(taskId, turtleId);
|
||||
this._db.updateTaskStatus(taskId, 'in_progress');
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error assigning task #${taskId}:`, e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track
|
||||
this._activeTasks.set(turtleId, { taskId, taskType: task.task_type });
|
||||
this._taskToTurtle.set(taskId, turtleId);
|
||||
|
||||
// Broadcast updates
|
||||
this._broadcast({ type: 'task_assigned', taskId, turtleId });
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'in_progress' });
|
||||
|
||||
// Set the turtle's state machine
|
||||
turtle.setState(mapping.state, stateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion (turtle went back to idle after executing).
|
||||
*/
|
||||
_handleTaskCompletion(taskId, turtleId) {
|
||||
console.log(`[TaskDispatcher] Task #${taskId} completed by Turtle #${turtleId}`);
|
||||
|
||||
try {
|
||||
this._db.completeTask(taskId);
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error completing task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
|
||||
this._broadcast({ type: 'task_completed', taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task failure (turtle disconnected, errored, etc.).
|
||||
* Re-queues the task as pending so another turtle can pick it up.
|
||||
*/
|
||||
_handleTaskFailure(taskId, turtleId, reason) {
|
||||
console.warn(`[TaskDispatcher] Task #${taskId} failed on Turtle #${turtleId}: ${reason}`);
|
||||
|
||||
try {
|
||||
// Re-queue: set back to pending, clear assignment
|
||||
this._db.updateTaskStatus(taskId, 'pending', reason);
|
||||
this._db.assignTask(taskId, null);
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error re-queuing task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'pending' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually cancel a running task (called via API).
|
||||
*/
|
||||
cancelTask(taskId) {
|
||||
const turtleId = this._taskToTurtle.get(taskId);
|
||||
if (turtleId !== undefined) {
|
||||
const turtle = this._turtles.get(turtleId);
|
||||
if (turtle) {
|
||||
turtle.setState('idle');
|
||||
}
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
}
|
||||
|
||||
try {
|
||||
this._db.updateTaskStatus(taskId, 'cancelled');
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error cancelling task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'cancelled' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific task is currently being executed.
|
||||
*/
|
||||
isTaskActive(taskId) {
|
||||
return this._taskToTurtle.has(taskId);
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Get connected, idle turtles not currently executing a dispatched task.
|
||||
*/
|
||||
_getAvailableTurtles() {
|
||||
const available = [];
|
||||
for (const [id, turtle] of this._turtles) {
|
||||
if (
|
||||
turtle.connected &&
|
||||
turtle.stateName === 'idle' &&
|
||||
!this._activeTasks.has(id)
|
||||
) {
|
||||
available.push(turtle);
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best turtle for a task.
|
||||
* If task_data has coordinates, pick the closest turtle with a known position.
|
||||
* Otherwise, pick the turtle with the most fuel.
|
||||
*/
|
||||
_pickBestTurtle(candidates, taskData) {
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Try to extract a target position from task data
|
||||
const target = this._extractTarget(taskData);
|
||||
|
||||
if (target) {
|
||||
// Sort by Manhattan distance to target
|
||||
let bestTurtle = candidates[0];
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const t of candidates) {
|
||||
if (t.position) {
|
||||
const dist = Math.abs(t.position.x - target.x)
|
||||
+ Math.abs(t.position.y - target.y)
|
||||
+ Math.abs(t.position.z - target.z);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestTurtle = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestTurtle;
|
||||
}
|
||||
|
||||
// No target — pick turtle with highest fuel
|
||||
return candidates.reduce((best, t) => (t._fuel > best._fuel ? t : best), candidates[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a target {x,y,z} from task_data.
|
||||
*/
|
||||
_extractTarget(data) {
|
||||
if (!data) return null;
|
||||
|
||||
// Direct target
|
||||
if (data.target && typeof data.target.x === 'number') return data.target;
|
||||
if (data.destination && typeof data.destination.x === 'number') return data.destination;
|
||||
|
||||
// Bounds — use center
|
||||
if (data.bounds) {
|
||||
const b = data.bounds;
|
||||
if (typeof b.minX === 'number' && typeof b.maxX === 'number') {
|
||||
return {
|
||||
x: Math.floor((b.minX + b.maxX) / 2),
|
||||
y: Math.floor((b.minY + b.maxY) / 2),
|
||||
z: Math.floor((b.minZ + b.maxZ) / 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinate pair (from TaskPanel)
|
||||
if (typeof data.startX === 'number' && typeof data.startZ === 'number') {
|
||||
return { x: data.startX, y: data.startY || 64, z: data.startZ };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
server/Turtle.js
133
server/Turtle.js
@@ -98,6 +98,11 @@ export class Turtle extends EventEmitter {
|
||||
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
|
||||
this.connected = false;
|
||||
this.lastUpdate = Date.now();
|
||||
@@ -288,9 +293,8 @@ export class Turtle extends EventEmitter {
|
||||
/**
|
||||
* Run the current state's act() generator in a loop
|
||||
*/
|
||||
async _runStateLoop() {
|
||||
async _runStateLoop(consecutiveErrors = 0) {
|
||||
const state = this._state;
|
||||
let consecutiveErrors = 0;
|
||||
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||
|
||||
try {
|
||||
@@ -312,14 +316,24 @@ export class Turtle extends EventEmitter {
|
||||
|
||||
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
|
||||
// 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(); // Restart the loop with the same state
|
||||
this._runStateLoop(consecutiveErrors);
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +358,10 @@ export class Turtle extends EventEmitter {
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
const pending = this._pendingCommands.get(uuid);
|
||||
if (pending) {
|
||||
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);
|
||||
@@ -402,15 +420,15 @@ export class Turtle extends EventEmitter {
|
||||
|
||||
const turn = (direction - this._facing + 4) % 4;
|
||||
if (turn === 1) {
|
||||
await this.exec('return turtle.turnRight()');
|
||||
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${direction}`);
|
||||
this._facing = direction;
|
||||
this._emitUpdate();
|
||||
} else if (turn === 2) {
|
||||
await this.exec('turtle.turnLeft(); return turtle.turnLeft()');
|
||||
await this.exec(`turtle.turnLeft(); turtle.turnLeft(); _G._turtleFacing = ${direction}`);
|
||||
this._facing = direction;
|
||||
this._emitUpdate();
|
||||
} else if (turn === 3) {
|
||||
await this.exec('return turtle.turnLeft()');
|
||||
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${direction}`);
|
||||
this._facing = direction;
|
||||
this._emitUpdate();
|
||||
}
|
||||
@@ -423,6 +441,8 @@ export class Turtle extends EventEmitter {
|
||||
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;
|
||||
@@ -435,6 +455,8 @@ export class Turtle extends EventEmitter {
|
||||
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;
|
||||
@@ -448,6 +470,8 @@ export class Turtle extends EventEmitter {
|
||||
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||
this.updatePositionForward();
|
||||
this._deleteBlockAtPosition(this._position);
|
||||
this._stepsSinceLastRefuel++;
|
||||
this._totalSteps++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -468,6 +492,8 @@ export class Turtle extends EventEmitter {
|
||||
this.position = pos;
|
||||
this._deleteBlockAtPosition(pos);
|
||||
}
|
||||
this._stepsSinceLastRefuel++;
|
||||
this._totalSteps++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -480,6 +506,8 @@ export class Turtle extends EventEmitter {
|
||||
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||
this.updatePositionUp();
|
||||
this._deleteBlockAtPosition(this._position);
|
||||
this._stepsSinceLastRefuel++;
|
||||
this._totalSteps++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -492,6 +520,8 @@ export class Turtle extends EventEmitter {
|
||||
if (result === true || (Array.isArray(result) && result[0] === true)) {
|
||||
this.updatePositionDown();
|
||||
this._deleteBlockAtPosition(this._position);
|
||||
this._stepsSinceLastRefuel++;
|
||||
this._totalSteps++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -723,10 +753,15 @@ export class Turtle extends EventEmitter {
|
||||
*/
|
||||
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._fuel = fuelLevel;
|
||||
if (fuelLevel != null) {
|
||||
this._totalFuelUsed += (fuelLevel - fuelBefore);
|
||||
this._fuel = fuelLevel;
|
||||
this._stepsSinceLastRefuel = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -794,6 +829,83 @@ export class Turtle extends EventEmitter {
|
||||
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
|
||||
*/
|
||||
@@ -1099,6 +1211,9 @@ export class Turtle extends EventEmitter {
|
||||
peripherals: this._peripherals,
|
||||
error: this._error,
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -85,12 +85,28 @@ export function initializeDatabase() {
|
||||
max_x INTEGER NOT NULL,
|
||||
max_y INTEGER NOT NULL,
|
||||
max_z INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
color TEXT DEFAULT '#4a8c2a',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate existing mining_areas table to add name/color columns if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('name')) {
|
||||
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
|
||||
}
|
||||
if (!columns.includes('color')) {
|
||||
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Mining statistics table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mining_stats (
|
||||
@@ -144,10 +160,22 @@ export function initializeDatabase() {
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate player_positions table to add label column if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('label')) {
|
||||
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Chunk analysis table (ore density per chunk)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
@@ -260,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
|
||||
return stmt.all(limit);
|
||||
}
|
||||
|
||||
export function getWorldBlockCount() {
|
||||
const row = db.prepare('SELECT COUNT(*) as cnt FROM world_blocks').get();
|
||||
return row ? row.cnt : 0;
|
||||
}
|
||||
|
||||
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM world_blocks
|
||||
@@ -347,12 +380,22 @@ export function getNextTask() {
|
||||
}
|
||||
|
||||
export function assignTask(taskId, turtleId) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(turtleId, Date.now(), taskId);
|
||||
if (turtleId === null || turtleId === undefined) {
|
||||
// Un-assign: clear turtle and revert to pending
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(Date.now(), taskId);
|
||||
} else {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(turtleId, Date.now(), taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTaskStatus(taskId, status, result = null) {
|
||||
@@ -394,16 +437,17 @@ export function getAllTasks(status = null) {
|
||||
}
|
||||
|
||||
// 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(`
|
||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
const result = stmt.run(
|
||||
turtleId,
|
||||
bounds.minX, bounds.minY, bounds.minZ,
|
||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||
areaName, color,
|
||||
status, now, now
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
@@ -423,6 +467,24 @@ export function updateMiningAreaStatus(areaId, status) {
|
||||
stmt.run(status, Date.now(), areaId);
|
||||
}
|
||||
|
||||
export function updateMiningArea(areaId, updates) {
|
||||
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
setClauses.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) return;
|
||||
setClauses.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(areaId);
|
||||
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...values);
|
||||
}
|
||||
|
||||
export function deleteMiningArea(areaId) {
|
||||
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
||||
return stmt.run(areaId);
|
||||
@@ -566,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
|
||||
}
|
||||
|
||||
// Player Positions
|
||||
export function savePlayerPosition(playerId, position) {
|
||||
export function savePlayerPosition(playerId, position, label = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(playerId, position.x, position.y, position.z, Date.now());
|
||||
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
|
||||
}
|
||||
|
||||
export function getPlayerPosition(playerId) {
|
||||
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?');
|
||||
return stmt.get(playerId);
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
|
||||
const row = stmt.get(playerId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllPlayerPositions() {
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions');
|
||||
return stmt.all();
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
|
||||
return stmt.all().map(row => ({
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
@@ -17,12 +19,14 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.2.2"
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
"nodemon": "^3.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
425
server/server.js
425
server/server.js
@@ -1,16 +1,26 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import { createRequire } from 'module';
|
||||
import * as db from './database.js';
|
||||
import { Turtle } from './Turtle.js';
|
||||
import { TaskDispatcher } from './TaskDispatcher.js';
|
||||
import { WorldBlockCache } from './WorldBlockCache.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const WS_PORT = 3002;
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createPlatformServer,
|
||||
setupGracefulShutdown,
|
||||
createProxyEndpoint,
|
||||
} = require('@cc-platform/server');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
// ========== Platform Server Setup ==========
|
||||
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) => {
|
||||
@@ -26,14 +36,14 @@ db.initializeDatabase();
|
||||
// Load persisted data from database
|
||||
console.log('📂 Loading persisted data from database...');
|
||||
const savedHomes = db.getAllTurtleHomes();
|
||||
const savedBlocks = db.getWorldBlocks();
|
||||
const blockCount = db.getWorldBlockCount();
|
||||
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
|
||||
const webClients = new Set();
|
||||
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 turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
|
||||
|
||||
@@ -42,20 +52,17 @@ for (const home of savedHomes) {
|
||||
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)
|
||||
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
|
||||
function broadcastToClients(data) {
|
||||
const message = JSON.stringify(data);
|
||||
@@ -99,13 +106,9 @@ function getOrCreateTurtle(turtleID) {
|
||||
|
||||
// ---- 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) => {
|
||||
// Queue eval command for webbridge to poll
|
||||
turtle.pendingCommands.push({
|
||||
...command,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
pushCommandToBridge(turtleID, command);
|
||||
});
|
||||
|
||||
// Store discovered blocks
|
||||
@@ -198,32 +201,179 @@ function getBlockPosition(turtlePos, facing, direction) {
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
// WebSocket server for web clients AND bridge connections
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// WebSocket server for web clients
|
||||
const wss = new WebSocketServer({ port: WS_PORT });
|
||||
// Track bridge connections separately
|
||||
const bridgeClients = new Set();
|
||||
|
||||
console.log(`🚀 Turtle Control Server starting...`);
|
||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||
console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`);
|
||||
/**
|
||||
* Push a command to the webbridge for a specific turtle.
|
||||
* 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
|
||||
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');
|
||||
webClients.add(ws);
|
||||
|
||||
// Send current turtle data and world blocks to new client
|
||||
const blocks = [];
|
||||
for (const [key, blockData] of worldBlocks.entries()) {
|
||||
const [x, y, z] = key.split(',').map(Number);
|
||||
blocks.push({ x, y, z, ...blockData });
|
||||
}
|
||||
const blocks = worldBlocks.getAllBlocksForAPI();
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
turtles: Array.from(turtles.values()).map(t => t.toJSON()),
|
||||
blocks: blocks
|
||||
blocks: blocks,
|
||||
players: db.getAllPlayerPositions()
|
||||
}));
|
||||
|
||||
ws.on('message', (message) => {
|
||||
@@ -460,14 +610,7 @@ app.get('/api/turtle/:id/home', (req, res) => {
|
||||
|
||||
// Get world blocks for map visualization
|
||||
app.get('/api/world/blocks', (req, res) => {
|
||||
const blocks = [];
|
||||
for (const [key, blockData] of worldBlocks.entries()) {
|
||||
const [x, y, z] = key.split(',').map(Number);
|
||||
blocks.push({
|
||||
x, y, z,
|
||||
...blockData
|
||||
});
|
||||
}
|
||||
const blocks = worldBlocks.getAllBlocksForAPI();
|
||||
res.json({ blocks });
|
||||
});
|
||||
|
||||
@@ -907,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 ==========
|
||||
|
||||
// Helper to format mining area for API response
|
||||
@@ -914,6 +1082,8 @@ function formatMiningArea(area) {
|
||||
return {
|
||||
areaID: area.id,
|
||||
turtleID: area.turtle_id,
|
||||
areaName: area.name || `Area #${area.id}`,
|
||||
color: area.color || '#4a8c2a',
|
||||
startX: area.min_x,
|
||||
startY: area.min_y,
|
||||
startZ: area.min_z,
|
||||
@@ -929,7 +1099,7 @@ function formatMiningArea(area) {
|
||||
// Save a mining area
|
||||
app.post('/api/mining-areas', (req, res) => {
|
||||
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;
|
||||
|
||||
// Support both {turtleId, bounds} and flat {startX,startY,...} formats
|
||||
@@ -942,7 +1112,7 @@ app.post('/api/mining-areas', (req, res) => {
|
||||
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();
|
||||
broadcastToClients({
|
||||
@@ -966,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) => {
|
||||
try {
|
||||
const areaId = parseInt(req.params.areaId);
|
||||
const { status } = req.body;
|
||||
db.updateMiningAreaStatus(areaId, status);
|
||||
const { status, name, color } = req.body;
|
||||
|
||||
// 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();
|
||||
broadcastToClients({
|
||||
@@ -1056,6 +1235,51 @@ 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
|
||||
app.get('/api/world/blocks/search', (req, res) => {
|
||||
try {
|
||||
@@ -1310,19 +1534,20 @@ app.post('/api/groups/:groupId/command', (req, res) => {
|
||||
// Player position endpoints
|
||||
app.post('/api/player/update', (req, res) => {
|
||||
try {
|
||||
const { playerID, position, timestamp } = req.body;
|
||||
const { playerID, position, timestamp, label } = req.body;
|
||||
|
||||
if (!playerID || !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
|
||||
broadcastToClients({
|
||||
type: 'player_update',
|
||||
playerID,
|
||||
position,
|
||||
label: label || null,
|
||||
timestamp: timestamp || Date.now()
|
||||
});
|
||||
|
||||
@@ -1601,15 +1826,83 @@ app.post('/api/turtle/:id/gps', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
db.closeDatabase();
|
||||
process.exit(0);
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`✅ Server ready!`);
|
||||
console.log(`\nConfigured turtles to send updates to:`);
|
||||
console.log(` http://localhost:${PORT}/api/turtle/update`);
|
||||
// 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;
|
||||
|
||||
if (diff === 1) {
|
||||
await this.exec('return turtle.turnRight()');
|
||||
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
|
||||
this.turtle.facing = targetFacing;
|
||||
} 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;
|
||||
} else if (diff === 3) {
|
||||
await this.exec('return turtle.turnLeft()');
|
||||
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${targetFacing}`);
|
||||
this.turtle.facing = targetFacing;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* ExploringState - Autonomous exploration to discover the world map
|
||||
* Similar to mining but focused on discovering blocks rather than mining them
|
||||
* ExploringState - Chunk-based spiral exploration to discover the world map
|
||||
*
|
||||
* Instead of random walking, this systematically explores in a spiral pattern
|
||||
* outward from the starting position, chunk by chunk (16x16 areas).
|
||||
* Within each chunk, it does a strip-mine pattern to scan all blocks.
|
||||
*
|
||||
* Inspired by runi95/turtle-control-panel's exploration pattern.
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
@@ -9,9 +14,19 @@ export class ExploringState extends BaseState {
|
||||
super(turtle, data);
|
||||
this.maxDistance = data.maxDistance || 200;
|
||||
this.minFuel = data.minFuel || 500;
|
||||
this.yLevel = data.yLevel || null; // null = stay at current Y
|
||||
this.blocksDiscovered = 0;
|
||||
this.stuckCounter = 0;
|
||||
this.visitedPositions = new Set();
|
||||
this.chunksExplored = 0;
|
||||
|
||||
// Spiral state
|
||||
this.spiralRing = data.spiralRing || 0;
|
||||
this.spiralSide = data.spiralSide || 0;
|
||||
this.spiralStep = data.spiralStep || 0;
|
||||
this.spiralChunkX = data.spiralChunkX ?? null;
|
||||
this.spiralChunkZ = data.spiralChunkZ ?? null;
|
||||
this.startChunkX = data.startChunkX ?? null;
|
||||
this.startChunkZ = data.startChunkZ ?? null;
|
||||
this.exploredChunks = new Set(data.exploredChunks || []);
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -19,69 +34,235 @@ export class ExploringState extends BaseState {
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Exploring - ${this.blocksDiscovered} blocks discovered`;
|
||||
return `Exploring - ${this.blocksDiscovered} blocks, ${this.chunksExplored} chunks (ring ${this.spiralRing})`;
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
// Safety checks
|
||||
// Safety: check fuel
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
||||
console.log(`[${this.turtle.id}] Low fuel, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check distance
|
||||
// Safety: check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety: check distance
|
||||
if (this._isTooFar()) {
|
||||
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring' });
|
||||
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++;
|
||||
}
|
||||
|
||||
// Mark position
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
||||
}
|
||||
// Advance spiral to next chunk
|
||||
this._advanceSpiral();
|
||||
|
||||
// Comprehensive scan
|
||||
const scanResult = await this.scanSurroundings();
|
||||
if (scanResult) {
|
||||
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();
|
||||
|
||||
// Exploration movement
|
||||
yield* this._exploreStep();
|
||||
|
||||
} catch (error) {
|
||||
const isTimeout = error.message?.includes('timed out');
|
||||
if (isTimeout) {
|
||||
console.warn(`[${this.turtle.id}] Exploration exec timeout, will retry next iteration`);
|
||||
// Wait longer before retrying after a timeout
|
||||
await this._sleep(3000);
|
||||
} else {
|
||||
// Non-timeout errors still propagate
|
||||
throw error;
|
||||
// Move forward (digging through obstacles)
|
||||
const moved = await this.moveForward(true);
|
||||
if (!moved) {
|
||||
// Stuck - try to go over
|
||||
await this.moveUp(true);
|
||||
const movedOver = await this.moveForward(true);
|
||||
if (movedOver) {
|
||||
await this.moveDown(true);
|
||||
} else {
|
||||
// Really stuck, skip rest of row
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(100);
|
||||
}
|
||||
|
||||
// Alternate direction for serpentine pattern
|
||||
forward = !forward;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a target position safely using pathfinding with simple fallback
|
||||
*/
|
||||
async *_navigateToSafe(target) {
|
||||
try {
|
||||
const success = yield* this.navigateTo(target, { canMine: true, maxAttempts: 500 });
|
||||
if (success) return;
|
||||
} catch (error) {
|
||||
// Pathfinding failed, use simple navigation
|
||||
}
|
||||
|
||||
// Fallback: simple direct navigation
|
||||
yield* this._simpleNavigate(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple direct navigation (X -> Z -> Y)
|
||||
*/
|
||||
async *_simpleNavigate(target) {
|
||||
const maxAttempts = 300;
|
||||
let attempts = 0;
|
||||
let stuckCount = 0;
|
||||
|
||||
while (attempts < maxAttempts && !this.cancelled) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const dx = target.x - pos.x;
|
||||
const dy = target.y - pos.y;
|
||||
const dz = target.z - pos.z;
|
||||
|
||||
// Close enough (within 2 blocks)
|
||||
if (Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && Math.abs(dz) <= 1) return;
|
||||
|
||||
attempts++;
|
||||
let moved = false;
|
||||
|
||||
// Move in X direction
|
||||
if (Math.abs(dx) > 1) {
|
||||
const facing = dx > 0 ? 1 : 3;
|
||||
await this.turnToFace(facing);
|
||||
moved = await this.moveForward(true);
|
||||
}
|
||||
// Then Z direction
|
||||
else if (Math.abs(dz) > 1) {
|
||||
const facing = dz > 0 ? 2 : 0;
|
||||
await this.turnToFace(facing);
|
||||
moved = await this.moveForward(true);
|
||||
}
|
||||
// Then Y direction
|
||||
else if (dy > 0) {
|
||||
moved = await this.moveUp(true);
|
||||
} else if (dy < 0) {
|
||||
moved = await this.moveDown(true);
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
stuckCount++;
|
||||
if (stuckCount > 5) {
|
||||
// Try going up and over
|
||||
await this.moveUp(true);
|
||||
await this.moveUp(true);
|
||||
stuckCount = 0;
|
||||
}
|
||||
} else {
|
||||
stuckCount = 0;
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +270,13 @@ export class ExploringState extends BaseState {
|
||||
const valuableOres = new Set([
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore',
|
||||
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
|
||||
'minecraft:gold_ore', 'minecraft:deepslate_gold_ore',
|
||||
'minecraft:iron_ore', 'minecraft:deepslate_iron_ore',
|
||||
'minecraft:lapis_ore', 'minecraft:deepslate_lapis_ore',
|
||||
'minecraft:redstone_ore', 'minecraft:deepslate_redstone_ore',
|
||||
'minecraft:copper_ore', 'minecraft:deepslate_copper_ore',
|
||||
'minecraft:coal_ore', 'minecraft:deepslate_coal_ore',
|
||||
'minecraft:ancient_debris',
|
||||
]);
|
||||
|
||||
const directions = [
|
||||
@@ -113,61 +301,54 @@ export class ExploringState extends BaseState {
|
||||
}
|
||||
}
|
||||
|
||||
async *_exploreStep() {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
// Favor horizontal movement heavily (85%)
|
||||
const r = Math.random() * 100;
|
||||
|
||||
if (r < 85) {
|
||||
// Try to find unvisited direction
|
||||
let moved = false;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const fwdPos = this.turtle.getBlockPositionInDirection('forward');
|
||||
if (fwdPos && !this.visitedPositions.has(`${fwdPos.x},${fwdPos.y},${fwdPos.z}`)) {
|
||||
const canMove = await this.exec('local h = turtle.inspect(); return not h');
|
||||
if (canMove) {
|
||||
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);
|
||||
/**
|
||||
* Advance the spiral to the next chunk position.
|
||||
* Standard clockwise spiral: start at center, go E, then S, W, N with increasing lengths.
|
||||
* Ring 0: just center
|
||||
* Ring 1: side lengths = 1,2,2,1 (total 6 chunks)
|
||||
* Ring R: 8*R chunks around the ring
|
||||
*/
|
||||
_advanceSpiral() {
|
||||
if (this.spiralRing === 0) {
|
||||
// Center done, start ring 1 going East
|
||||
this.spiralRing = 1;
|
||||
this.spiralSide = 0;
|
||||
this.spiralStep = 0;
|
||||
this.spiralChunkX = this.startChunkX + 1;
|
||||
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stuckCounter > 6) {
|
||||
await this.moveUp(true);
|
||||
this.stuckCounter = 0;
|
||||
// Side directions: S, W, N, E
|
||||
const sideDirs = [
|
||||
{ dx: 0, dz: 1 }, // South
|
||||
{ dx: -1, dz: 0 }, // West
|
||||
{ dx: 0, dz: -1 }, // North
|
||||
{ dx: 1, dz: 0 }, // East
|
||||
];
|
||||
|
||||
this.spiralStep++;
|
||||
const sideLength = this.spiralRing * 2;
|
||||
|
||||
if (this.spiralStep >= sideLength) {
|
||||
this.spiralStep = 0;
|
||||
this.spiralSide++;
|
||||
|
||||
if (this.spiralSide >= 4) {
|
||||
// Done with this ring, move to next
|
||||
this.spiralRing++;
|
||||
this.spiralSide = 0;
|
||||
// Jump to start of new ring (one East, one North from current)
|
||||
this.spiralChunkX = this.startChunkX + this.spiralRing;
|
||||
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
// Move in current side direction
|
||||
const dir = sideDirs[this.spiralSide];
|
||||
this.spiralChunkX += dir.dx;
|
||||
this.spiralChunkZ += dir.dz;
|
||||
}
|
||||
|
||||
_isTooFar() {
|
||||
@@ -184,6 +365,15 @@ export class ExploringState extends BaseState {
|
||||
data: {
|
||||
maxDistance: this.maxDistance,
|
||||
minFuel: this.minFuel,
|
||||
yLevel: this.yLevel,
|
||||
spiralRing: this.spiralRing,
|
||||
spiralSide: this.spiralSide,
|
||||
spiralStep: this.spiralStep,
|
||||
spiralChunkX: this.spiralChunkX,
|
||||
spiralChunkZ: this.spiralChunkZ,
|
||||
startChunkX: this.startChunkX,
|
||||
startChunkZ: this.startChunkZ,
|
||||
exploredChunks: [...this.exploredChunks],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,12 +13,20 @@ export class IdleState extends BaseState {
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.checkFuel();
|
||||
await this.scanSurroundings();
|
||||
try {
|
||||
await this.checkFuel();
|
||||
} catch (e) {
|
||||
// Fuel check failed (likely command timeout) — not critical in idle
|
||||
// Just wait longer before trying again
|
||||
await this._sleep(30000);
|
||||
}
|
||||
yield;
|
||||
await this._sleep(5000);
|
||||
await this._sleep(10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1194
turtle.lua
1194
turtle.lua
File diff suppressed because it is too large
Load Diff
1045
webbridge.lua
1045
webbridge.lua
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user