Compare commits
77 Commits
60c5b3aaba
...
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 |
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;
|
||||
}
|
||||
}
|
||||
126
server/Turtle.js
126
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();
|
||||
@@ -318,7 +323,17 @@ export class Turtle extends EventEmitter {
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,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);
|
||||
@@ -401,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();
|
||||
}
|
||||
@@ -422,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;
|
||||
@@ -434,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;
|
||||
@@ -447,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;
|
||||
}
|
||||
@@ -467,6 +492,8 @@ export class Turtle extends EventEmitter {
|
||||
this.position = pos;
|
||||
this._deleteBlockAtPosition(pos);
|
||||
}
|
||||
this._stepsSinceLastRefuel++;
|
||||
this._totalSteps++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -479,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;
|
||||
}
|
||||
@@ -491,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;
|
||||
}
|
||||
@@ -722,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;
|
||||
}
|
||||
|
||||
@@ -793,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
|
||||
*/
|
||||
@@ -1098,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"
|
||||
}
|
||||
}
|
||||
|
||||
382
server/server.js
382
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({
|
||||
@@ -1069,7 +1248,7 @@ app.post('/api/chunks/:x/:z/analyze', (req, res) => {
|
||||
const maxZ = minZ + 15;
|
||||
|
||||
// Get all blocks in the chunk from the DB
|
||||
const blocks = db.getBlocksInArea(minX, -64, minZ, maxX, 320, maxZ);
|
||||
const blocks = db.getWorldBlocksInArea(minX, -64, minZ, maxX, 320, maxZ);
|
||||
|
||||
// Count ores
|
||||
const oreCounts = {};
|
||||
@@ -1355,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()
|
||||
});
|
||||
|
||||
@@ -1646,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
228
turtle.lua
228
turtle.lua
@@ -3,9 +3,11 @@
|
||||
-- This script only handles: eval execution, status broadcasting,
|
||||
-- GPS tracking, inventory/peripheral events.
|
||||
|
||||
local CHANNEL_RECEIVE = 100
|
||||
local CHANNEL_SEND = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- State tracking (lightweight - server drives everything)
|
||||
local state = {
|
||||
@@ -23,11 +25,13 @@ local config = {
|
||||
}
|
||||
|
||||
-- Check for modem
|
||||
local modem = peripheral.find("modem")
|
||||
local WebBridge = require('platform.webbridge')
|
||||
local modem, modemSide = WebBridge.findModem(true)
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
-- Open command channel (respects dual-mode migration)
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.command' })
|
||||
|
||||
print("Server-Driven Turtle v5 (Pure Eval Protocol)")
|
||||
print("ID: " .. os.getComputerID())
|
||||
@@ -203,7 +207,215 @@ end
|
||||
|
||||
syncHomeWithServer()
|
||||
updateFuel()
|
||||
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven)")
|
||||
|
||||
-- ========== Movement Wrapping (heading + position tracking) ==========
|
||||
|
||||
-- Heading: 0=south(+z), 1=west(-x), 2=north(-z), 3=east(+x)
|
||||
local MOVE_DELTA = {
|
||||
[0] = { x = 0, z = 1 }, -- south
|
||||
[1] = { x = -1, z = 0 }, -- west
|
||||
[2] = { x = 0, z = -1 }, -- north
|
||||
[3] = { x = 1, z = 0 }, -- east
|
||||
}
|
||||
|
||||
local _rawForward = turtle.forward
|
||||
local _rawBack = turtle.back
|
||||
local _rawUp = turtle.up
|
||||
local _rawDown = turtle.down
|
||||
local _rawTurnLeft = turtle.turnLeft
|
||||
local _rawTurnRight = turtle.turnRight
|
||||
|
||||
turtle.forward = function()
|
||||
local ok, reason = _rawForward()
|
||||
if ok and state.position then
|
||||
local d = MOVE_DELTA[state.facing]
|
||||
state.position.x = state.position.x + d.x
|
||||
state.position.z = state.position.z + d.z
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.back = function()
|
||||
local ok, reason = _rawBack()
|
||||
if ok and state.position then
|
||||
local d = MOVE_DELTA[state.facing]
|
||||
state.position.x = state.position.x - d.x
|
||||
state.position.z = state.position.z - d.z
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.up = function()
|
||||
local ok, reason = _rawUp()
|
||||
if ok and state.position then
|
||||
state.position.y = state.position.y + 1
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.down = function()
|
||||
local ok, reason = _rawDown()
|
||||
if ok and state.position then
|
||||
state.position.y = state.position.y - 1
|
||||
end
|
||||
return ok, reason
|
||||
end
|
||||
|
||||
turtle.turnLeft = function()
|
||||
local ok = _rawTurnLeft()
|
||||
if ok then
|
||||
state.facing = (state.facing + 3) % 4
|
||||
_G._turtleFacing = state.facing
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
turtle.turnRight = function()
|
||||
local ok = _rawTurnRight()
|
||||
if ok then
|
||||
state.facing = (state.facing + 1) % 4
|
||||
_G._turtleFacing = state.facing
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Detect heading via GPS triangulation
|
||||
local function detectHeading()
|
||||
local x1, _, z1 = gps.locate(2)
|
||||
if not x1 then return false end
|
||||
if _rawForward() then
|
||||
local x2, _, z2 = gps.locate(2)
|
||||
_rawBack()
|
||||
if x2 then
|
||||
local dx = math.floor(x2) - math.floor(x1)
|
||||
local dz = math.floor(z2) - math.floor(z1)
|
||||
if dz > 0 then state.facing = 0 -- south
|
||||
elseif dz < 0 then state.facing = 2 -- north
|
||||
elseif dx > 0 then state.facing = 3 -- east
|
||||
elseif dx < 0 then state.facing = 1 -- west
|
||||
end
|
||||
_G._turtleFacing = state.facing
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if state.position then
|
||||
if detectHeading() then
|
||||
print("Heading: " .. ({"south","west","north","east"})[state.facing + 1])
|
||||
else
|
||||
print("Heading: unknown (blocked)")
|
||||
end
|
||||
end
|
||||
|
||||
-- ========== Pathfinding Module ==========
|
||||
|
||||
local pathfind = {}
|
||||
|
||||
--- Face a target heading.
|
||||
function pathfind.face(targetH)
|
||||
targetH = targetH % 4
|
||||
while state.facing ~= targetH do
|
||||
local diff = (targetH - state.facing) % 4
|
||||
if diff == 1 then
|
||||
turtle.turnRight()
|
||||
elseif diff == 3 then
|
||||
turtle.turnLeft()
|
||||
else
|
||||
turtle.turnRight()
|
||||
turtle.turnRight()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Navigate to target coordinates with simple obstacle avoidance.
|
||||
-- @param tx, ty, tz target position
|
||||
-- @param options { dig = false, maxAttempts = 256 }
|
||||
-- @return true or false, error
|
||||
function pathfind.goto(tx, ty, tz, options)
|
||||
options = options or {}
|
||||
local maxAttempts = options.maxAttempts or 256
|
||||
local dig = options.dig or false
|
||||
local attempts = 0
|
||||
|
||||
while attempts < maxAttempts do
|
||||
local pos = state.position
|
||||
if not pos then return false, "No GPS position" end
|
||||
|
||||
-- Arrived?
|
||||
if pos.x == tx and pos.y == ty and pos.z == tz then
|
||||
return true
|
||||
end
|
||||
|
||||
attempts = attempts + 1
|
||||
local moved = false
|
||||
|
||||
-- Priority: Y first (get to correct height), then X, then Z
|
||||
if not moved and pos.y ~= ty then
|
||||
if pos.y < ty then
|
||||
moved = turtle.up()
|
||||
if not moved and dig then turtle.digUp(); moved = turtle.up() end
|
||||
else
|
||||
moved = turtle.down()
|
||||
if not moved and dig then turtle.digDown(); moved = turtle.down() end
|
||||
end
|
||||
end
|
||||
|
||||
if not moved and pos.x ~= tx then
|
||||
pathfind.face(tx > pos.x and 3 or 1)
|
||||
moved = turtle.forward()
|
||||
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||
end
|
||||
|
||||
if not moved and pos.z ~= tz then
|
||||
pathfind.face(tz > pos.z and 0 or 2)
|
||||
moved = turtle.forward()
|
||||
if not moved and dig then turtle.dig(); moved = turtle.forward() end
|
||||
end
|
||||
|
||||
-- Obstacle avoidance: try going around
|
||||
if not moved then
|
||||
if turtle.up() then
|
||||
moved = true
|
||||
elseif turtle.down() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnRight()
|
||||
if turtle.forward() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnLeft()
|
||||
turtle.turnLeft()
|
||||
if turtle.forward() then
|
||||
moved = true
|
||||
else
|
||||
turtle.turnRight() -- restore heading
|
||||
return false, "Stuck at " .. pos.x .. "," .. pos.y .. "," .. pos.z
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return false, "Max attempts exceeded"
|
||||
end
|
||||
|
||||
--- Go home (to saved home position).
|
||||
function pathfind.goHome(options)
|
||||
if not state.homePosition then return false, "No home position set" end
|
||||
return pathfind.goto(state.homePosition.x, state.homePosition.y, state.homePosition.z, options)
|
||||
end
|
||||
|
||||
--- Get current heading name.
|
||||
function pathfind.headingName()
|
||||
return ({"south","west","north","east"})[state.facing + 1]
|
||||
end
|
||||
|
||||
-- Expose globally for eval access
|
||||
_G._pathfind = pathfind
|
||||
|
||||
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven + pathfinding)")
|
||||
broadcastStatus()
|
||||
|
||||
-- ========== Main Loop ==========
|
||||
@@ -234,9 +446,11 @@ parallel.waitForAny(
|
||||
|
||||
function()
|
||||
-- Command processing (eval protocol)
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (100) and target (4210) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
if channel == CHANNEL_RECEIVE then
|
||||
if Channels.match('remoteturtle.command', channel) then
|
||||
processMessage(channel, message)
|
||||
end
|
||||
end
|
||||
|
||||
1045
webbridge.lua
1045
webbridge.lua
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user