Compare commits
23 Commits
9796f73e64
...
dca14643c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca14643c6 | ||
|
|
67498b93bf | ||
|
|
df00a6be70 | ||
|
|
f23ebcb05a | ||
|
|
08281d88fe | ||
|
|
882dc75762 | ||
|
|
4c774ac306 | ||
|
|
e38551dfc2 | ||
|
|
87c103ea9e | ||
|
|
836e7b61c7 | ||
|
|
f8baadce3a | ||
|
|
a5aec5800e | ||
|
|
0e41ee12c5 | ||
|
|
bdc4dade9f | ||
|
|
e8ef1d669b | ||
|
|
3dfc4237c6 | ||
|
|
1492f59de7 | ||
|
|
d2185ac493 | ||
|
|
fa98e86055 | ||
|
|
1b08777f88 | ||
|
|
68d4ee52c9 | ||
|
|
6f73fdd0ed | ||
|
|
0f22c8e49b |
@@ -3,7 +3,7 @@ import { useTurtleStore } from '../store/turtleStore';
|
||||
import './ControlPanel.css';
|
||||
|
||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
const mode = turtle.mode || 'unknown';
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||
const inventoryCount = turtle.inventory?.length || 0;
|
||||
|
||||
@@ -11,8 +11,14 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
mining: '#4ade80',
|
||||
exploring: '#60a5fa',
|
||||
returning: '#f59e0b',
|
||||
goHome: '#f59e0b',
|
||||
idle: '#9ca3af',
|
||||
manual: '#a78bfa',
|
||||
refueling: '#ef4444',
|
||||
farming: '#22c55e',
|
||||
dumpInventory: '#a855f7',
|
||||
dumping: '#a855f7',
|
||||
moving: '#06b6d4',
|
||||
unknown: '#6b7280'
|
||||
};
|
||||
|
||||
@@ -20,12 +26,12 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
<div
|
||||
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onSelect}
|
||||
style={{ borderColor: modeColors[mode] }}
|
||||
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
||||
>
|
||||
<div className="turtle-header">
|
||||
<h3>Turtle {turtle.turtleID}</h3>
|
||||
<span className="mode-badge" style={{ background: modeColors[mode] }}>
|
||||
{mode}
|
||||
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||
{activeState}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +73,7 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
|
||||
function TurtleDetails({ turtle }) {
|
||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
|
||||
if (!turtle) {
|
||||
return (
|
||||
@@ -80,6 +87,12 @@ function TurtleDetails({ turtle }) {
|
||||
sendCommand(turtle.turtleID, command, param);
|
||||
};
|
||||
|
||||
const handleStateChange = (stateName, data = {}) => {
|
||||
setTurtleState(turtle.turtleID, stateName, data);
|
||||
};
|
||||
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
|
||||
return (
|
||||
<div className="turtle-details">
|
||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
||||
@@ -88,9 +101,15 @@ function TurtleDetails({ turtle }) {
|
||||
<h3>Status</h3>
|
||||
<div className="status-grid">
|
||||
<div className="status-item">
|
||||
<span className="label">Mode:</span>
|
||||
<span className="value">{turtle.mode || 'unknown'}</span>
|
||||
<span className="label">State:</span>
|
||||
<span className="value">{activeState}</span>
|
||||
</div>
|
||||
{turtle.stateDescription && (
|
||||
<div className="status-item">
|
||||
<span className="label">Activity:</span>
|
||||
<span className="value">{turtle.stateDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Fuel:</span>
|
||||
<span className="value">
|
||||
@@ -119,7 +138,77 @@ function TurtleDetails({ turtle }) {
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Commands</h3>
|
||||
<h3>State Machine</h3>
|
||||
<div className="command-grid">
|
||||
<button
|
||||
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
||||
onClick={() => { handleStateChange('idle'); handleCommand('stop'); }}
|
||||
title="Stop and go idle"
|
||||
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⏸️ Idle
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
||||
onClick={() => { handleStateChange('exploring'); handleCommand('explore'); }}
|
||||
title="Autonomous exploration"
|
||||
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🔍 Explore
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
||||
onClick={() => { handleStateChange('mining'); handleCommand('explore'); }}
|
||||
title="Mining with ore priority"
|
||||
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛏️ Mine
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('farming')}
|
||||
title="Automated farming"
|
||||
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🌾 Farm
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
||||
onClick={() => { handleStateChange('goHome'); handleCommand('returnHome'); }}
|
||||
title="Navigate home"
|
||||
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🏠 Go Home
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
||||
onClick={() => { handleStateChange('refueling'); handleCommand('refuel'); }}
|
||||
title="Auto-refuel from inventory"
|
||||
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛽ Refuel
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('dumpInventory')}
|
||||
title="Dump inventory into nearby container"
|
||||
style={activeState === 'dumpInventory' || activeState === 'dumping' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
📦 Dump
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'moving' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('moving')}
|
||||
title="Navigate to target"
|
||||
style={activeState === 'moving' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🧭 Move To
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Legacy Commands</h3>
|
||||
<div className="command-grid">
|
||||
<button
|
||||
className="command-btn explore"
|
||||
|
||||
@@ -19,14 +19,37 @@ const TEXTURE_MAP = {
|
||||
'minecraft:oak_planks': 'oak_planks',
|
||||
'minecraft:glass': 'glass',
|
||||
|
||||
// Logs
|
||||
'minecraft:spruce_log': 'spruce_log',
|
||||
'minecraft:birch_log': 'birch_log',
|
||||
'minecraft:jungle_log': 'jungle_log',
|
||||
'minecraft:acacia_log': 'acacia_log',
|
||||
'minecraft:dark_oak_log': 'dark_oak_log',
|
||||
'minecraft:mangrove_log': 'mangrove_log',
|
||||
'minecraft:cherry_log': 'cherry_log',
|
||||
|
||||
// Planks
|
||||
'minecraft:spruce_planks': 'spruce_planks',
|
||||
'minecraft:birch_planks': 'birch_planks',
|
||||
'minecraft:jungle_planks': 'jungle_planks',
|
||||
'minecraft:acacia_planks': 'acacia_planks',
|
||||
'minecraft:dark_oak_planks': 'dark_oak_planks',
|
||||
|
||||
// Stone variants
|
||||
'minecraft:granite': 'granite',
|
||||
'minecraft:polished_granite': 'polished_granite',
|
||||
'minecraft:diorite': 'diorite',
|
||||
'minecraft:polished_diorite': 'polished_diorite',
|
||||
'minecraft:andesite': 'andesite',
|
||||
'minecraft:polished_andesite': 'polished_andesite',
|
||||
'minecraft:deepslate': 'deepslate',
|
||||
'minecraft:cobbled_deepslate': 'cobbled_deepslate',
|
||||
'minecraft:tuff': 'tuff',
|
||||
'minecraft:calcite': 'calcite',
|
||||
'minecraft:dripstone_block': 'dripstone_block',
|
||||
'minecraft:smooth_stone': 'smooth_stone',
|
||||
'minecraft:sandstone': 'sandstone',
|
||||
'minecraft:red_sandstone': 'red_sandstone',
|
||||
|
||||
// Ores (overworld)
|
||||
'minecraft:coal_ore': 'coal_ore',
|
||||
@@ -48,22 +71,81 @@ const TEXTURE_MAP = {
|
||||
'minecraft:deepslate_lapis_ore': 'deepslate_lapis_ore',
|
||||
'minecraft:deepslate_copper_ore': 'deepslate_copper_ore',
|
||||
|
||||
// Nether ores
|
||||
'minecraft:nether_gold_ore': 'nether_gold_ore',
|
||||
'minecraft:nether_quartz_ore': 'nether_quartz_ore',
|
||||
'minecraft:ancient_debris': 'ancient_debris_side',
|
||||
|
||||
// Special blocks
|
||||
'minecraft:obsidian': 'obsidian',
|
||||
'minecraft:crying_obsidian': 'crying_obsidian',
|
||||
'minecraft:netherrack': 'netherrack',
|
||||
'minecraft:soul_sand': 'soul_sand',
|
||||
'minecraft:soul_soil': 'soul_soil',
|
||||
'minecraft:glowstone': 'glowstone',
|
||||
'minecraft:end_stone': 'end_stone',
|
||||
'minecraft:water': 'water_still',
|
||||
'minecraft:lava': 'lava_still',
|
||||
'minecraft:magma_block': 'magma',
|
||||
'minecraft:amethyst_block': 'amethyst_block',
|
||||
'minecraft:budding_amethyst': 'budding_amethyst',
|
||||
'minecraft:moss_block': 'moss_block',
|
||||
'minecraft:clay': 'clay',
|
||||
'minecraft:terracotta': 'terracotta',
|
||||
'minecraft:packed_ice': 'packed_ice',
|
||||
'minecraft:blue_ice': 'blue_ice',
|
||||
'minecraft:ice': 'ice',
|
||||
'minecraft:snow_block': 'snow',
|
||||
'minecraft:mycelium': 'mycelium_side',
|
||||
'minecraft:podzol': 'podzol_side',
|
||||
|
||||
// Building blocks
|
||||
'minecraft:bricks': 'bricks',
|
||||
'minecraft:stone_bricks': 'stone_bricks',
|
||||
'minecraft:cracked_stone_bricks': 'cracked_stone_bricks',
|
||||
'minecraft:mossy_cobblestone': 'mossy_cobblestone',
|
||||
'minecraft:mossy_stone_bricks': 'mossy_stone_bricks',
|
||||
'minecraft:prismarine': 'prismarine',
|
||||
'minecraft:dark_prismarine': 'dark_prismarine',
|
||||
'minecraft:purpur_block': 'purpur_block',
|
||||
'minecraft:nether_bricks': 'nether_bricks',
|
||||
'minecraft:red_nether_bricks': 'red_nether_bricks',
|
||||
'minecraft:quartz_block': 'quartz_block_side',
|
||||
'minecraft:basalt': 'basalt_side',
|
||||
'minecraft:polished_basalt': 'polished_basalt_side',
|
||||
'minecraft:blackstone': 'blackstone',
|
||||
'minecraft:polished_blackstone': 'polished_blackstone',
|
||||
'minecraft:gilded_blackstone': 'gilded_blackstone',
|
||||
|
||||
// Functional blocks
|
||||
'minecraft:crafting_table': 'crafting_table_front',
|
||||
'minecraft:furnace': 'furnace_front',
|
||||
'minecraft:chest': 'barrel_side',
|
||||
'minecraft:barrel': 'barrel_side',
|
||||
'minecraft:bookshelf': 'bookshelf',
|
||||
'minecraft:tnt': 'tnt_side',
|
||||
'minecraft:spawner': 'spawner',
|
||||
};
|
||||
|
||||
// Blocks with different top/side/bottom textures
|
||||
const MULTI_FACE_BLOCKS = {
|
||||
'minecraft:grass_block': { top: 'grass_block_top', side: 'grass_block_side', bottom: 'dirt' },
|
||||
'minecraft:mycelium': { top: 'mycelium_top', side: 'mycelium_side', bottom: 'dirt' },
|
||||
'minecraft:podzol': { top: 'podzol_top', side: 'podzol_side', bottom: 'dirt' },
|
||||
'minecraft:oak_log': { top: 'oak_log_top', side: 'oak_log', bottom: 'oak_log_top' },
|
||||
'minecraft:spruce_log': { top: 'spruce_log_top', side: 'spruce_log', bottom: 'spruce_log_top' },
|
||||
'minecraft:birch_log': { top: 'birch_log_top', side: 'birch_log', bottom: 'birch_log_top' },
|
||||
'minecraft:jungle_log': { top: 'jungle_log_top', side: 'jungle_log', bottom: 'jungle_log_top' },
|
||||
'minecraft:acacia_log': { top: 'acacia_log_top', side: 'acacia_log', bottom: 'acacia_log_top' },
|
||||
'minecraft:dark_oak_log': { top: 'dark_oak_log_top', side: 'dark_oak_log', bottom: 'dark_oak_log_top' },
|
||||
'minecraft:sandstone': { top: 'sandstone_top', side: 'sandstone', bottom: 'sandstone_bottom' },
|
||||
'minecraft:red_sandstone': { top: 'red_sandstone_top', side: 'red_sandstone', bottom: 'red_sandstone_bottom' },
|
||||
'minecraft:crafting_table': { top: 'crafting_table_top', side: 'crafting_table_front', bottom: 'oak_planks' },
|
||||
'minecraft:furnace': { top: 'furnace_top', side: 'furnace_front', bottom: 'furnace_top' },
|
||||
'minecraft:tnt': { top: 'tnt_top', side: 'tnt_side', bottom: 'tnt_bottom' },
|
||||
'minecraft:quartz_block': { top: 'quartz_block_top', side: 'quartz_block_side', bottom: 'quartz_block_bottom' },
|
||||
'minecraft:basalt': { top: 'basalt_top', side: 'basalt_side', bottom: 'basalt_top' },
|
||||
'minecraft:barrel': { top: 'barrel_top', side: 'barrel_side', bottom: 'barrel_bottom' },
|
||||
};
|
||||
|
||||
// Function to get texture URL from a Minecraft texture service
|
||||
@@ -73,15 +155,18 @@ function getTextureURL(blockName) {
|
||||
return `https://raw.githubusercontent.com/InventivetalentDev/minecraft-assets/1.20.4/assets/minecraft/textures/block/${textureName}.png`;
|
||||
}
|
||||
|
||||
// Custom hook to load multiple textures
|
||||
// Custom hook to load multiple textures (including multi-face)
|
||||
function useMinecraftTextures(blocks) {
|
||||
const [textures, setTextures] = useState({});
|
||||
const [multiFaceTextures, setMultiFaceTextures] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const uniqueBlocks = [...new Set(blocks.map(b => b.name))];
|
||||
const loadedTextures = {};
|
||||
const loadedMultiFace = {};
|
||||
let totalToLoad = 0;
|
||||
let loadedCount = 0;
|
||||
|
||||
if (uniqueBlocks.length === 0) {
|
||||
@@ -89,8 +174,60 @@ function useMinecraftTextures(blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all unique texture names to load
|
||||
const textureNames = new Set();
|
||||
uniqueBlocks.forEach(blockName => {
|
||||
const url = getTextureURL(blockName);
|
||||
const multiFace = MULTI_FACE_BLOCKS[blockName];
|
||||
if (multiFace) {
|
||||
textureNames.add(multiFace.top);
|
||||
textureNames.add(multiFace.side);
|
||||
textureNames.add(multiFace.bottom);
|
||||
} else {
|
||||
const textureName = TEXTURE_MAP[blockName] || null;
|
||||
if (textureName) textureNames.add(textureName);
|
||||
}
|
||||
});
|
||||
|
||||
totalToLoad = textureNames.size;
|
||||
if (totalToLoad === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedTexturesByName = {};
|
||||
|
||||
const checkDone = () => {
|
||||
loadedCount++;
|
||||
if (loadedCount >= totalToLoad) {
|
||||
// Build per-block texture maps
|
||||
uniqueBlocks.forEach(blockName => {
|
||||
const multiFace = MULTI_FACE_BLOCKS[blockName];
|
||||
if (multiFace) {
|
||||
const top = loadedTexturesByName[multiFace.top];
|
||||
const side = loadedTexturesByName[multiFace.side];
|
||||
const bottom = loadedTexturesByName[multiFace.bottom];
|
||||
if (top && side && bottom) {
|
||||
loadedMultiFace[blockName] = [side, side, top, bottom, side, side];
|
||||
} else {
|
||||
// Fallback to single texture
|
||||
loadedTextures[blockName] = side || top || bottom || null;
|
||||
}
|
||||
} else {
|
||||
const textureName = TEXTURE_MAP[blockName];
|
||||
if (textureName && loadedTexturesByName[textureName]) {
|
||||
loadedTextures[blockName] = loadedTexturesByName[textureName];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setTextures(loadedTextures);
|
||||
setMultiFaceTextures(loadedMultiFace);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
textureNames.forEach(textureName => {
|
||||
const url = `https://raw.githubusercontent.com/InventivetalentDev/minecraft-assets/1.20.4/assets/minecraft/textures/block/${textureName}.png`;
|
||||
|
||||
textureLoader.load(
|
||||
url,
|
||||
@@ -99,35 +236,26 @@ function useMinecraftTextures(blocks) {
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
loadedTextures[blockName] = texture;
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === uniqueBlocks.length) {
|
||||
setTextures(loadedTextures);
|
||||
setLoading(false);
|
||||
}
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
loadedTexturesByName[textureName] = texture;
|
||||
checkDone();
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
console.warn(`Failed to load texture for ${blockName}, using color fallback`);
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === uniqueBlocks.length) {
|
||||
setTextures(loadedTextures);
|
||||
setLoading(false);
|
||||
}
|
||||
() => {
|
||||
checkDone();
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [blocks]);
|
||||
|
||||
return { textures, loading };
|
||||
return { textures, multiFaceTextures, loading };
|
||||
}
|
||||
|
||||
// Minecraft-style turtle model
|
||||
function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
const groupRef = useRef();
|
||||
const { position, facing, mode } = turtle;
|
||||
const { position, facing, mode, state } = turtle;
|
||||
const activeMode = state || mode || 'idle';
|
||||
|
||||
useFrame((state) => {
|
||||
if (groupRef.current && isSelected) {
|
||||
@@ -138,17 +266,16 @@ function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
if (!position) return null;
|
||||
|
||||
// Calculate rotation based on facing (0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X))
|
||||
// In Three.js, 0 rotation faces +Z, so we need to adjust:
|
||||
// facing 0 (North/-Z) = rotate 180° = Math.PI
|
||||
// facing 1 (East/+X) = rotate 270° = -Math.PI/2
|
||||
// facing 2 (South/+Z) = rotate 0° = 0
|
||||
// facing 3 (West/-X) = rotate 90° = Math.PI/2
|
||||
const facingRotations = [Math.PI, -Math.PI/2, 0, Math.PI/2];
|
||||
const rotation = facing !== undefined ? [0, facingRotations[facing], 0] : [0, 0, 0];
|
||||
|
||||
const color = mode === 'mining' ? '#4ade80' :
|
||||
mode === 'exploring' ? '#60a5fa' :
|
||||
mode === 'returning' ? '#f59e0b' :
|
||||
const color = activeMode === 'mining' ? '#4ade80' :
|
||||
activeMode === 'exploring' ? '#60a5fa' :
|
||||
activeMode === 'returning' || activeMode === 'goHome' ? '#f59e0b' :
|
||||
activeMode === 'refueling' ? '#ef4444' :
|
||||
activeMode === 'farming' ? '#22c55e' :
|
||||
activeMode === 'dumpInventory' || activeMode === 'dumping' ? '#a855f7' :
|
||||
activeMode === 'moving' ? '#06b6d4' :
|
||||
'#9ca3af';
|
||||
|
||||
return (
|
||||
@@ -210,7 +337,7 @@ function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
<boxGeometry args={[0.08, 0.08, 0.01]} />
|
||||
<meshStandardMaterial
|
||||
color="#00ff00"
|
||||
emissive={mode === 'mining' ? "#00ff00" : mode === 'returning' ? "#ffaa00" : "#00aaff"}
|
||||
emissive={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
|
||||
emissiveIntensity={1.5}
|
||||
toneMapped={false}
|
||||
/>
|
||||
@@ -219,7 +346,7 @@ function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
<boxGeometry args={[0.08, 0.08, 0.01]} />
|
||||
<meshStandardMaterial
|
||||
color="#00ff00"
|
||||
emissive={mode === 'mining' ? "#00ff00" : mode === 'returning' ? "#ffaa00" : "#00aaff"}
|
||||
emissive={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
|
||||
emissiveIntensity={1.5}
|
||||
toneMapped={false}
|
||||
/>
|
||||
@@ -228,7 +355,7 @@ function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
{/* LED glow */}
|
||||
<pointLight
|
||||
position={[0, 0.2, 0.85]}
|
||||
color={mode === 'mining' ? "#00ff00" : mode === 'returning' ? "#ffaa00" : "#00aaff"}
|
||||
color={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
|
||||
intensity={0.5}
|
||||
distance={2}
|
||||
/>
|
||||
@@ -270,7 +397,7 @@ function TurtleModel({ turtle, isSelected, onClick }) {
|
||||
</mesh>
|
||||
|
||||
{/* Tool/Mining arm - only show when mining */}
|
||||
{mode === 'mining' && (
|
||||
{activeMode === 'mining' && (
|
||||
<>
|
||||
<mesh position={[0, 0.1, 0.7]}>
|
||||
<boxGeometry args={[0.1, 0.1, 0.3]} />
|
||||
@@ -409,7 +536,7 @@ function getBlockAppearance(blockName) {
|
||||
// WorldBlocks component to render discovered blocks with Minecraft textures
|
||||
function WorldBlocks({ blocks }) {
|
||||
const [hoveredBlock, setHoveredBlock] = useState(null);
|
||||
const { textures, loading } = useMinecraftTextures(blocks);
|
||||
const { textures, multiFaceTextures, loading } = useMinecraftTextures(blocks);
|
||||
|
||||
// Group blocks by block name for better performance
|
||||
const blockGroups = useMemo(() => {
|
||||
@@ -434,6 +561,7 @@ function WorldBlocks({ blocks }) {
|
||||
{blockGroups.map(([blockName, blockList]) => {
|
||||
const appearance = getBlockAppearance(blockName);
|
||||
const texture = textures[blockName];
|
||||
const multiFace = multiFaceTextures[blockName];
|
||||
|
||||
return (
|
||||
<group key={blockName}>
|
||||
@@ -452,16 +580,36 @@ function WorldBlocks({ blocks }) {
|
||||
onPointerOut={() => setHoveredBlock(null)}
|
||||
>
|
||||
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
color={texture ? '#ffffff' : appearance.color}
|
||||
emissive={appearance.emissive || appearance.color}
|
||||
emissiveIntensity={isHovered ? (appearance.intensity || 0.05) + 0.2 : (appearance.intensity || 0.05)}
|
||||
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8}
|
||||
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0}
|
||||
transparent
|
||||
opacity={isHovered ? 0.98 : 0.9}
|
||||
/>
|
||||
{multiFace ? (
|
||||
// Multi-face block (different top/side/bottom)
|
||||
// Box face order: +x, -x, +y, -y, +z, -z
|
||||
multiFace.map((faceTexture, i) => (
|
||||
<meshStandardMaterial
|
||||
key={i}
|
||||
attach={`material-${i}`}
|
||||
map={faceTexture}
|
||||
color="#ffffff"
|
||||
emissive={appearance.emissive || '#000000'}
|
||||
emissiveIntensity={isHovered ? 0.25 : (appearance.intensity || 0.02)}
|
||||
roughness={0.8}
|
||||
metalness={0.0}
|
||||
transparent
|
||||
opacity={isHovered ? 0.98 : 0.9}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Single texture or color fallback
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
color={texture ? '#ffffff' : appearance.color}
|
||||
emissive={appearance.emissive || appearance.color}
|
||||
emissiveIntensity={isHovered ? (appearance.intensity || 0.05) + 0.2 : (appearance.intensity || 0.05)}
|
||||
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8}
|
||||
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0}
|
||||
transparent
|
||||
opacity={isHovered ? 0.98 : 0.9}
|
||||
/>
|
||||
)}
|
||||
</mesh>
|
||||
|
||||
{/* Block label on hover */}
|
||||
|
||||
@@ -86,6 +86,17 @@ export const useTurtleStore = create((set, get) => ({
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'blocks_discovered') {
|
||||
if (data.blocks && Array.isArray(data.blocks)) {
|
||||
set(state => {
|
||||
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
data.blocks.forEach(block => {
|
||||
const key = `${block.x},${block.y},${block.z}`;
|
||||
blockMap.set(key, block);
|
||||
});
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
@@ -161,7 +172,7 @@ export const useTurtleStore = create((set, get) => ({
|
||||
sendCommand: async (turtleId, command, param = null) => {
|
||||
const { ws } = get();
|
||||
|
||||
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${param})` : '');
|
||||
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${JSON.stringify(param)})` : '');
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
@@ -187,6 +198,49 @@ export const useTurtleStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Set turtle state machine state via REST API
|
||||
setTurtleState: async (turtleId, stateName, stateData = {}) => {
|
||||
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: stateName, data: stateData })
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log(' ✅ State set:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error setting state:', error);
|
||||
// Fallback: send via WebSocket as a command
|
||||
const { ws } = get();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
turtleID: turtleId,
|
||||
command: 'set_state',
|
||||
param: { state: stateName, data: stateData }
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Execute arbitrary Lua code on a turtle
|
||||
execOnTurtle: async (turtleId, code) => {
|
||||
console.log(`💻 Exec on turtle ${turtleId}:`, code);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error executing:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Helper getters
|
||||
getTurtleArray: () => {
|
||||
return Object.values(get().turtles);
|
||||
|
||||
119
pocketremote.lua
119
pocketremote.lua
@@ -23,7 +23,7 @@ local w, h = term.getSize()
|
||||
-- Tracked turtles
|
||||
local turtles = {}
|
||||
local selectedTurtle = nil
|
||||
local viewMode = "overview" -- overview, detail, manual
|
||||
local viewMode = "overview" -- overview, detail, manual, modes
|
||||
|
||||
-- Button system
|
||||
local buttons = {}
|
||||
@@ -90,6 +90,16 @@ local function sendCommand(turtleID, command, param)
|
||||
})
|
||||
end
|
||||
|
||||
-- Send state change command to turtle (new protocol)
|
||||
local function sendStateCommand(turtleID, stateName, stateData)
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "state_change",
|
||||
state = stateName,
|
||||
data = stateData or {},
|
||||
target = turtleID
|
||||
})
|
||||
end
|
||||
|
||||
-- Helper function to format fuel display
|
||||
local function formatFuel(fuel)
|
||||
if not fuel then
|
||||
@@ -127,7 +137,7 @@ local function drawOverview()
|
||||
-- Turtle info box
|
||||
term.setCursorPos(1, y)
|
||||
term.setTextColor(selected and colors.lime or colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
if turtle.position then
|
||||
print(string.format(" %d,%d,%d",
|
||||
@@ -197,7 +207,7 @@ local function drawDetail()
|
||||
term.setTextColor(colors.white)
|
||||
|
||||
print("")
|
||||
print("Mode: " .. (turtle.mode or "unknown"))
|
||||
print("State: " .. (turtle.state or turtle.mode or "idle"))
|
||||
print("Fuel: " .. formatFuel(turtle.fuel))
|
||||
|
||||
if turtle.position then
|
||||
@@ -249,12 +259,16 @@ local function drawDetail()
|
||||
end, colors.red)
|
||||
|
||||
btnY = h - 4
|
||||
addButton(1, btnY, 12, 2, "MANUAL", function()
|
||||
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||
viewMode = "manual"
|
||||
sendCommand(turtle.turtleID, "manual")
|
||||
end, colors.purple)
|
||||
|
||||
addButton(14, btnY, 12, 2, "SET HOME", function()
|
||||
addButton(10, btnY, 8, 2, "MODES", function()
|
||||
viewMode = "modes"
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(19, btnY, 7, 2, "HOME*", function()
|
||||
sendCommand(turtle.turtleID, "setHome")
|
||||
end, colors.blue)
|
||||
|
||||
@@ -369,6 +383,80 @@ local function drawManual()
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function drawModes()
|
||||
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||
viewMode = "overview"
|
||||
return
|
||||
end
|
||||
|
||||
clearButtons()
|
||||
local turtle = turtles[selectedTurtle]
|
||||
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setTextColor(colors.cyan)
|
||||
print("=STATE MACHINE=")
|
||||
term.setTextColor(colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
-- State buttons
|
||||
local btnY = 4
|
||||
local btnW = 12
|
||||
|
||||
addButton(1, btnY, btnW, 2, "IDLE", function()
|
||||
sendStateCommand(turtle.turtleID, "idle")
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.gray)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "EXPLORE", function()
|
||||
sendStateCommand(turtle.turtleID, "exploring")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.green)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "MINE", function()
|
||||
sendStateCommand(turtle.turtleID, "mining")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "FARM", function()
|
||||
sendStateCommand(turtle.turtleID, "farming")
|
||||
end, colors.lime)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "GO HOME", function()
|
||||
sendStateCommand(turtle.turtleID, "goHome")
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "REFUEL", function()
|
||||
sendStateCommand(turtle.turtleID, "refueling")
|
||||
sendCommand(turtle.turtleID, "refuel")
|
||||
end, colors.red)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "DUMP INV", function()
|
||||
sendStateCommand(turtle.turtleID, "dumpInventory")
|
||||
end, colors.brown)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "MOVE TO", function()
|
||||
-- Could prompt for coordinates, for now just sends moving state
|
||||
sendStateCommand(turtle.turtleID, "moving")
|
||||
end, colors.lightBlue)
|
||||
|
||||
-- Back button
|
||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||
viewMode = "detail"
|
||||
end, colors.gray)
|
||||
|
||||
for _, btn in ipairs(buttons) do
|
||||
drawButton(btn)
|
||||
end
|
||||
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function draw()
|
||||
if viewMode == "overview" then
|
||||
drawOverview()
|
||||
@@ -376,6 +464,8 @@ local function draw()
|
||||
drawDetail()
|
||||
elseif viewMode == "manual" then
|
||||
drawManual()
|
||||
elseif viewMode == "modes" then
|
||||
drawModes()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -458,6 +548,10 @@ parallel.waitForAny(
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
-- Preserve state if not in message
|
||||
if not message.state then
|
||||
message.state = t.state or message.mode or "idle"
|
||||
end
|
||||
turtles[i] = message
|
||||
found = true
|
||||
break
|
||||
@@ -465,6 +559,9 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
if not found then
|
||||
if not message.state then
|
||||
message.state = message.mode or "idle"
|
||||
end
|
||||
table.insert(turtles, message)
|
||||
if not selectedTurtle then
|
||||
selectedTurtle = 1
|
||||
@@ -472,8 +569,16 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
draw()
|
||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
|
||||
-- Response from turtle
|
||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" then
|
||||
-- State change confirmation or other response
|
||||
if message.type == "state_changed" and message.turtleID then
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
turtles[i].state = message.state
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
draw()
|
||||
end
|
||||
end
|
||||
|
||||
438
server/Turtle.js
Normal file
438
server/Turtle.js
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Turtle - Server-side turtle wrapper with state machine and command correlation
|
||||
*
|
||||
* Each connected turtle gets a Turtle instance that manages:
|
||||
* - State machine (idle, mining, exploring, etc.)
|
||||
* - UUID-based command-response correlation
|
||||
* - Position/facing/inventory tracking
|
||||
* - Event emission for real-time UI updates
|
||||
*
|
||||
* Inspired by runi95/turtle-control-panel Turtle class.
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
IdleState,
|
||||
MovingState,
|
||||
MiningState,
|
||||
ExploringState,
|
||||
GoHomeState,
|
||||
RefuelingState,
|
||||
DumpInventoryState,
|
||||
FarmingState,
|
||||
} from './states/index.js';
|
||||
|
||||
const STATE_MAP = {
|
||||
idle: IdleState,
|
||||
moving: MovingState,
|
||||
mining: MiningState,
|
||||
exploring: ExploringState,
|
||||
goHome: GoHomeState,
|
||||
returning: GoHomeState,
|
||||
refueling: RefuelingState,
|
||||
dumpInventory: DumpInventoryState,
|
||||
dumping: DumpInventoryState,
|
||||
farming: FarmingState,
|
||||
};
|
||||
|
||||
// Timeout for exec() commands (ms)
|
||||
const EXEC_TIMEOUT = 30000;
|
||||
|
||||
export class Turtle extends EventEmitter {
|
||||
/**
|
||||
* @param {number} id - The ComputerCraft turtle ID
|
||||
* @param {Object} server - Reference to server context (worldBlocks, broadcastToClients, etc.)
|
||||
*/
|
||||
constructor(id, server) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.server = server;
|
||||
|
||||
// Turtle state
|
||||
this._position = null;
|
||||
this._homePosition = null;
|
||||
this._facing = 0;
|
||||
this._fuel = 0;
|
||||
this._inventory = {};
|
||||
this._label = null;
|
||||
|
||||
// State machine
|
||||
this._state = null;
|
||||
this._stateRunner = null; // The running async generator loop
|
||||
|
||||
// Command correlation
|
||||
this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout }
|
||||
this._messageIndex = 0;
|
||||
|
||||
// Connection tracking
|
||||
this.connected = false;
|
||||
this.lastUpdate = Date.now();
|
||||
this.lastStatusBroadcast = 0;
|
||||
|
||||
// Legacy command queue (for pocket computer compatibility)
|
||||
this.pendingLegacyCommands = [];
|
||||
|
||||
// Start in idle state
|
||||
this.setState('idle');
|
||||
}
|
||||
|
||||
// ========== Property Getters/Setters with Change Events ==========
|
||||
|
||||
get position() {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
set position(value) {
|
||||
const changed = !this._position ||
|
||||
this._position.x !== value?.x ||
|
||||
this._position.y !== value?.y ||
|
||||
this._position.z !== value?.z;
|
||||
|
||||
this._position = value;
|
||||
if (changed) this._emitUpdate();
|
||||
}
|
||||
|
||||
get homePosition() {
|
||||
return this._homePosition;
|
||||
}
|
||||
|
||||
set homePosition(value) {
|
||||
this._homePosition = value;
|
||||
this._emitUpdate();
|
||||
}
|
||||
|
||||
get facing() {
|
||||
return this._facing;
|
||||
}
|
||||
|
||||
set facing(value) {
|
||||
this._facing = value;
|
||||
this._emitUpdate();
|
||||
}
|
||||
|
||||
get fuel() {
|
||||
return this._fuel;
|
||||
}
|
||||
|
||||
set fuel(value) {
|
||||
this._fuel = value;
|
||||
}
|
||||
|
||||
get inventory() {
|
||||
return this._inventory;
|
||||
}
|
||||
|
||||
set inventory(value) {
|
||||
this._inventory = value;
|
||||
this._emitUpdate();
|
||||
}
|
||||
|
||||
get stateName() {
|
||||
return this._state?.name || 'idle';
|
||||
}
|
||||
|
||||
get stateDescription() {
|
||||
return this._state?.description || 'Idle';
|
||||
}
|
||||
|
||||
// ========== State Machine ==========
|
||||
|
||||
/**
|
||||
* Set the turtle's state. Cancels the current state and starts the new one.
|
||||
* @param {string} stateName - The state name (e.g., 'mining', 'idle')
|
||||
* @param {Object} data - State-specific initialization data
|
||||
*/
|
||||
setState(stateName, data = {}) {
|
||||
// Cancel current state
|
||||
if (this._state) {
|
||||
this._state.cancel();
|
||||
}
|
||||
|
||||
// Create new state
|
||||
const StateClass = STATE_MAP[stateName];
|
||||
if (!StateClass) {
|
||||
console.error(`[Turtle ${this.id}] Unknown state: ${stateName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._state = new StateClass(this, data);
|
||||
console.log(`[Turtle ${this.id}] State: ${this._state.name} - ${this._state.description}`);
|
||||
|
||||
// Run the state's act() generator loop
|
||||
this._runStateLoop();
|
||||
this._emitUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the current state's act() generator in a loop
|
||||
*/
|
||||
async _runStateLoop() {
|
||||
const state = this._state;
|
||||
|
||||
try {
|
||||
const generator = state.act();
|
||||
|
||||
for await (const _ of generator) {
|
||||
// Check if state was cancelled/changed while running
|
||||
if (this._state !== state) {
|
||||
return; // State changed, stop this generator
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this._state === state) {
|
||||
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
|
||||
this.setState('idle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Command Execution (UUID-based) ==========
|
||||
|
||||
/**
|
||||
* Execute Lua code on the turtle and return the result.
|
||||
* Uses UUID-based command-response correlation.
|
||||
*
|
||||
* @param {string} luaCode - The Lua code to execute
|
||||
* @param {number} timeout - Timeout in ms (default: 30s)
|
||||
* @returns {Promise<any>} The result of the Lua execution
|
||||
*/
|
||||
exec(luaCode, timeout = EXEC_TIMEOUT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uuid = randomUUID();
|
||||
const messageIndex = this._messageIndex++;
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
this._pendingCommands.delete(uuid);
|
||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
// Store the pending command
|
||||
this._pendingCommands.set(uuid, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timer);
|
||||
this._pendingCommands.delete(uuid);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
this._pendingCommands.delete(uuid);
|
||||
reject(error);
|
||||
},
|
||||
timeout: timer,
|
||||
luaCode,
|
||||
sentAt: Date.now(),
|
||||
});
|
||||
|
||||
// Send the command to the turtle (via webbridge modem relay)
|
||||
this._sendExecCommand(uuid, messageIndex, luaCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an eval command to the turtle
|
||||
*/
|
||||
_sendExecCommand(uuid, index, luaCode) {
|
||||
const command = {
|
||||
type: 'eval',
|
||||
uuid,
|
||||
index,
|
||||
code: luaCode,
|
||||
target: this.id,
|
||||
};
|
||||
|
||||
// Emit to server for forwarding to webbridge
|
||||
this.emit('sendCommand', command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a response from the turtle (matched by UUID)
|
||||
*/
|
||||
handleResponse(uuid, result, error) {
|
||||
const pending = this._pendingCommands.get(uuid);
|
||||
if (!pending) {
|
||||
// Response for unknown/expired command - ignore
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== Position Tracking ==========
|
||||
|
||||
updatePositionForward() {
|
||||
if (!this._position) return;
|
||||
const pos = { ...this._position };
|
||||
if (this._facing === 0) pos.z -= 1;
|
||||
else if (this._facing === 1) pos.x += 1;
|
||||
else if (this._facing === 2) pos.z += 1;
|
||||
else if (this._facing === 3) pos.x -= 1;
|
||||
this.position = pos;
|
||||
}
|
||||
|
||||
updatePositionUp() {
|
||||
if (!this._position) return;
|
||||
this.position = { ...this._position, y: this._position.y + 1 };
|
||||
}
|
||||
|
||||
updatePositionDown() {
|
||||
if (!this._position) return;
|
||||
this.position = { ...this._position, y: this._position.y - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the world position of a block in a given direction from the turtle
|
||||
*/
|
||||
getBlockPositionInDirection(direction) {
|
||||
if (!this._position) return null;
|
||||
const pos = { ...this._position };
|
||||
|
||||
if (direction === 'up') {
|
||||
pos.y += 1;
|
||||
} else if (direction === 'down') {
|
||||
pos.y -= 1;
|
||||
} else if (direction === 'forward') {
|
||||
if (this._facing === 0) pos.z -= 1;
|
||||
else if (this._facing === 1) pos.x += 1;
|
||||
else if (this._facing === 2) pos.z += 1;
|
||||
else if (this._facing === 3) pos.x -= 1;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process scan results and update world blocks on the server
|
||||
*/
|
||||
processScanResults(scanResult) {
|
||||
if (!scanResult || !this._position) return;
|
||||
|
||||
const blocks = [];
|
||||
|
||||
// Handle directional scan results
|
||||
for (const [dir, blockData] of Object.entries(scanResult)) {
|
||||
let blockPos;
|
||||
if (dir === 'up') {
|
||||
blockPos = { x: this._position.x, y: this._position.y + 1, z: this._position.z };
|
||||
} else if (dir === 'down') {
|
||||
blockPos = { x: this._position.x, y: this._position.y - 1, z: this._position.z };
|
||||
} else if (dir === 'forward') {
|
||||
blockPos = this.getBlockPositionInDirection('forward');
|
||||
} else if (dir.startsWith('h')) {
|
||||
// Horizontal direction by facing number (h0, h1, h2, h3)
|
||||
const facingNum = parseInt(dir.substring(1));
|
||||
const tempPos = { ...this._position };
|
||||
if (facingNum === 0) tempPos.z -= 1;
|
||||
else if (facingNum === 1) tempPos.x += 1;
|
||||
else if (facingNum === 2) tempPos.z += 1;
|
||||
else if (facingNum === 3) tempPos.x -= 1;
|
||||
blockPos = tempPos;
|
||||
}
|
||||
|
||||
if (blockPos && blockData && blockData.name) {
|
||||
blocks.push({
|
||||
x: blockPos.x,
|
||||
y: blockPos.y,
|
||||
z: blockPos.z,
|
||||
name: blockData.name,
|
||||
metadata: blockData.metadata || 0,
|
||||
discoveredBy: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store blocks in server world map
|
||||
if (blocks.length > 0) {
|
||||
this.emit('blocksDiscovered', blocks);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Status & Serialization ==========
|
||||
|
||||
/**
|
||||
* Update from a legacy status message (modem protocol)
|
||||
*/
|
||||
updateFromStatus(statusData) {
|
||||
this.lastUpdate = Date.now();
|
||||
this.connected = true;
|
||||
|
||||
if (statusData.position) {
|
||||
this._position = statusData.position;
|
||||
}
|
||||
if (statusData.homePosition) {
|
||||
this._homePosition = statusData.homePosition;
|
||||
}
|
||||
if (statusData.facing !== undefined) {
|
||||
this._facing = statusData.facing;
|
||||
}
|
||||
if (statusData.fuel !== undefined) {
|
||||
this._fuel = statusData.fuel;
|
||||
}
|
||||
if (statusData.inventory) {
|
||||
this._inventory = statusData.inventory;
|
||||
}
|
||||
|
||||
this._emitUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get serializable turtle data for clients
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
turtleID: this.id,
|
||||
position: this._position,
|
||||
homePosition: this._homePosition,
|
||||
facing: this._facing,
|
||||
fuel: this._fuel,
|
||||
inventory: this._inventory,
|
||||
inventoryCount: Array.isArray(this._inventory)
|
||||
? this._inventory.length
|
||||
: Object.keys(this._inventory).length,
|
||||
mode: this.stateName,
|
||||
state: this.stateName,
|
||||
stateDescription: this.stateDescription,
|
||||
connected: this.connected,
|
||||
lastUpdate: this.lastUpdate,
|
||||
label: this._label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an update event for broadcasting to clients
|
||||
*/
|
||||
_emitUpdate() {
|
||||
// Throttle updates to max once per 100ms
|
||||
const now = Date.now();
|
||||
if (now - this.lastStatusBroadcast < 100) return;
|
||||
this.lastStatusBroadcast = now;
|
||||
|
||||
this.emit('update', this.toJSON());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when turtle disconnects
|
||||
*/
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
|
||||
// Cancel current state
|
||||
if (this._state) {
|
||||
this._state.cancel();
|
||||
}
|
||||
|
||||
// Reject all pending commands
|
||||
for (const [uuid, pending] of this._pendingCommands) {
|
||||
pending.reject(new Error('Turtle disconnected'));
|
||||
}
|
||||
this._pendingCommands.clear();
|
||||
|
||||
this.emit('disconnect', this.id);
|
||||
}
|
||||
}
|
||||
398
server/server.js
398
server/server.js
@@ -3,13 +3,14 @@ import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import * as db from './database.js';
|
||||
import { Turtle } from './Turtle.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const WS_PORT = 3002;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// Initialize database
|
||||
db.initializeDatabase();
|
||||
@@ -23,11 +24,23 @@ console.log(` Loaded ${savedBlocks.length} world blocks`);
|
||||
|
||||
// Store connected web clients and turtle data
|
||||
const webClients = new Set();
|
||||
const turtleData = new Map(); // turtleID -> turtle state
|
||||
const turtles = new Map(); // turtleID -> Turtle instance
|
||||
const worldBlocks = new Map(); // "x,y,z" -> {name, metadata, discoveredBy, timestamp}
|
||||
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
|
||||
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
|
||||
|
||||
// Legacy compat: turtleData getter (returns serialized Turtle data)
|
||||
const turtleData = {
|
||||
has(id) { return turtles.has(id); },
|
||||
get(id) { const t = turtles.get(id); return t ? t.toJSON() : undefined; },
|
||||
set(id, _val) { /* no-op, use getOrCreateTurtle */ },
|
||||
delete(id) { return turtles.delete(id); },
|
||||
get size() { return turtles.size; },
|
||||
entries() { return Array.from(turtles.entries()).map(([id, t]) => [id, t.toJSON()])[Symbol.iterator](); },
|
||||
values() { return Array.from(turtles.values()).map(t => t.toJSON())[Symbol.iterator](); },
|
||||
keys() { return turtles.keys(); },
|
||||
};
|
||||
|
||||
// Load saved homes into memory
|
||||
for (const home of savedHomes) {
|
||||
turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z });
|
||||
@@ -57,22 +70,91 @@ function broadcastToClients(data) {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Turtle Instance Management ==========
|
||||
|
||||
/**
|
||||
* Get or create a Turtle instance for the given ID.
|
||||
* Wires up event handlers for the state machine and command forwarding.
|
||||
*/
|
||||
function getOrCreateTurtle(turtleID) {
|
||||
if (turtles.has(turtleID)) {
|
||||
return turtles.get(turtleID);
|
||||
}
|
||||
|
||||
console.log(`✨ Creating Turtle instance for #${turtleID}`);
|
||||
const turtle = new Turtle(turtleID, {
|
||||
worldBlocks,
|
||||
broadcastToClients,
|
||||
storeBlock,
|
||||
db,
|
||||
});
|
||||
|
||||
// Restore home position from DB
|
||||
const savedHome = turtleHomes.get(turtleID);
|
||||
if (savedHome) {
|
||||
turtle.homePosition = savedHome;
|
||||
}
|
||||
|
||||
// ---- Event handlers ----
|
||||
|
||||
// Forward eval commands to webbridge via pending command queue
|
||||
turtle.on('sendCommand', (command) => {
|
||||
// Queue eval command for webbridge to poll
|
||||
turtle.pendingLegacyCommands.push({
|
||||
...command,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// Store discovered blocks
|
||||
turtle.on('blocksDiscovered', (blocks) => {
|
||||
for (const block of blocks) {
|
||||
storeBlock(block.x, block.y, block.z, { name: block.name, metadata: block.metadata }, block.discoveredBy);
|
||||
}
|
||||
// Broadcast blocks to web clients
|
||||
broadcastToClients({
|
||||
type: 'blocks_discovered',
|
||||
blocks: blocks.map(b => ({
|
||||
x: b.x, y: b.y, z: b.z,
|
||||
name: b.name,
|
||||
metadata: b.metadata,
|
||||
discoveredBy: b.discoveredBy,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast turtle updates to web clients
|
||||
turtle.on('update', (data) => {
|
||||
broadcastToClients({
|
||||
type: 'turtle_update',
|
||||
turtle: data,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle turtle disconnect
|
||||
turtle.on('disconnect', (id) => {
|
||||
broadcastToClients({
|
||||
type: 'turtle_removed',
|
||||
turtleID: id,
|
||||
});
|
||||
});
|
||||
|
||||
turtles.set(turtleID, turtle);
|
||||
return turtle;
|
||||
}
|
||||
|
||||
// Cleanup stale turtles periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
for (const [turtleID, turtle] of turtleData.entries()) {
|
||||
for (const [turtleID, turtle] of turtles.entries()) {
|
||||
if (now - turtle.lastUpdate > TURTLE_TIMEOUT) {
|
||||
console.log(`🔌 Turtle ${turtleID} timed out (last seen ${Math.floor((now - turtle.lastUpdate) / 1000)}s ago)`);
|
||||
turtleData.delete(turtleID);
|
||||
turtle.disconnect();
|
||||
turtles.delete(turtleID);
|
||||
removedCount++;
|
||||
|
||||
// Notify web clients
|
||||
broadcastToClients({
|
||||
type: 'turtle_removed',
|
||||
turtleID: turtleID
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +187,6 @@ function getBlockPosition(turtlePos, facing, direction) {
|
||||
} else if (direction === 'down') {
|
||||
pos.y -= 1;
|
||||
} else if (direction === 'forward') {
|
||||
// Calculate based on facing direction
|
||||
if (facing === 0) pos.z -= 1; // North
|
||||
else if (facing === 1) pos.x += 1; // East
|
||||
else if (facing === 2) pos.z += 1; // South
|
||||
@@ -139,7 +220,7 @@ wss.on('connection', (ws) => {
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial_state',
|
||||
turtles: Array.from(turtleData.values()),
|
||||
turtles: Array.from(turtles.values()).map(t => t.toJSON()),
|
||||
blocks: blocks
|
||||
}));
|
||||
|
||||
@@ -150,21 +231,28 @@ wss.on('connection', (ws) => {
|
||||
|
||||
// Handle commands from web client
|
||||
if (data.type === 'command') {
|
||||
// Store command for turtle to poll
|
||||
const turtleID = data.turtleID;
|
||||
if (turtleData.has(turtleID)) {
|
||||
const turtle = turtleData.get(turtleID);
|
||||
turtle.pendingCommands = turtle.pendingCommands || [];
|
||||
turtle.pendingCommands.push({
|
||||
command: data.command,
|
||||
param: data.param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
console.log(`✅ Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${data.param})` : '');
|
||||
console.log(` Pending commands: ${turtle.pendingCommands.length}`);
|
||||
const turtle = turtles.get(turtleID);
|
||||
|
||||
if (turtle) {
|
||||
// Check for state change commands
|
||||
if (data.command === 'set_state' || data.command === 'setState') {
|
||||
const stateName = data.param?.state || data.param;
|
||||
const stateData = data.param?.data || {};
|
||||
turtle.setState(stateName, stateData);
|
||||
console.log(`✅ State set for turtle ${turtleID}: ${stateName}`);
|
||||
} else {
|
||||
// Legacy command - queue for webbridge polling
|
||||
turtle.pendingLegacyCommands.push({
|
||||
command: data.command,
|
||||
param: data.param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
console.log(`✅ Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${JSON.stringify(data.param)})` : '');
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ Turtle ${turtleID} not found in turtleData`);
|
||||
console.log(` Available turtles:`, Array.from(turtleData.keys()));
|
||||
console.log(`❌ Turtle ${turtleID} not found`);
|
||||
console.log(` Available turtles:`, Array.from(turtles.keys()));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -190,25 +278,22 @@ app.post('/api/turtle/update', (req, res) => {
|
||||
|
||||
console.log(`🐢 Status update from Turtle ${turtleID}`);
|
||||
|
||||
// Update turtle data
|
||||
if (!turtleData.has(turtleID)) {
|
||||
console.log(`✨ New turtle registered: ${turtleID}`);
|
||||
}
|
||||
|
||||
// Merge with existing data, keeping server's home position if it exists
|
||||
const existingData = turtleData.get(turtleID) || {};
|
||||
const serverHome = turtleHomes.get(turtleID);
|
||||
// Get or create Turtle instance
|
||||
const turtle = getOrCreateTurtle(turtleID);
|
||||
|
||||
turtleData.set(turtleID, {
|
||||
...existingData,
|
||||
...turtleUpdate,
|
||||
homePosition: serverHome || turtleUpdate.homePosition, // Server's home takes precedence
|
||||
lastUpdate: Date.now()
|
||||
});
|
||||
// Update turtle from status data
|
||||
turtle.updateFromStatus(turtleUpdate);
|
||||
|
||||
// Server's home takes precedence
|
||||
const serverHome = turtleHomes.get(turtleID);
|
||||
if (serverHome) {
|
||||
turtle.homePosition = serverHome;
|
||||
}
|
||||
|
||||
// If turtle reports a home but server doesn't have one, store it
|
||||
if (turtleUpdate.homePosition && !serverHome) {
|
||||
turtleHomes.set(turtleID, turtleUpdate.homePosition);
|
||||
turtle.homePosition = turtleUpdate.homePosition;
|
||||
console.log(` 📍 Stored home position for turtle ${turtleID}:`, turtleUpdate.homePosition);
|
||||
}
|
||||
|
||||
@@ -225,7 +310,7 @@ app.post('/api/turtle/update', (req, res) => {
|
||||
// Broadcast to web clients
|
||||
broadcastToClients({
|
||||
type: 'turtle_update',
|
||||
turtle: turtleData.get(turtleID)
|
||||
turtle: turtle.toJSON()
|
||||
});
|
||||
|
||||
// Send back server-authoritative data
|
||||
@@ -240,25 +325,32 @@ app.post('/api/turtle/update', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoint for turtles to poll for commands
|
||||
// Endpoint for webbridge to poll for commands (includes eval + legacy)
|
||||
app.get('/api/turtle/:id/commands', (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const turtle = turtles.get(turtleID);
|
||||
|
||||
if (turtleData.has(turtleID)) {
|
||||
const turtle = turtleData.get(turtleID);
|
||||
const commands = turtle.pendingCommands || [];
|
||||
if (turtle) {
|
||||
// Combine eval commands + legacy commands
|
||||
const commands = turtle.pendingLegacyCommands || [];
|
||||
|
||||
// Clean up old commands (older than 30 seconds - they're probably lost)
|
||||
// Clean up old commands (older than 30 seconds)
|
||||
const now = Date.now();
|
||||
turtle.pendingCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
turtle.pendingLegacyCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||||
|
||||
if (turtle.pendingCommands.length > 0) {
|
||||
console.log(`📤 Sending ${turtle.pendingCommands.length} command(s) to turtle ${turtleID}`);
|
||||
turtle.pendingCommands.forEach(cmd => console.log(` - ${cmd.command}`, cmd.param ? `(${cmd.param})` : ''));
|
||||
if (turtle.pendingLegacyCommands.length > 0) {
|
||||
console.log(`📤 Sending ${turtle.pendingLegacyCommands.length} command(s) to turtle ${turtleID}`);
|
||||
turtle.pendingLegacyCommands.forEach(cmd => {
|
||||
if (cmd.type === 'eval') {
|
||||
console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`);
|
||||
} else {
|
||||
console.log(` - ${cmd.command}`, cmd.param ? `(${JSON.stringify(cmd.param)})` : '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ commands: turtle.pendingCommands });
|
||||
res.json({ commands: turtle.pendingLegacyCommands });
|
||||
} else {
|
||||
res.json({ commands: [] });
|
||||
}
|
||||
@@ -272,14 +364,14 @@ app.get('/api/turtle/:id/commands', (req, res) => {
|
||||
app.post('/api/turtle/:id/commands/ack', (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const turtle = turtles.get(turtleID);
|
||||
|
||||
if (turtleData.has(turtleID)) {
|
||||
const turtle = turtleData.get(turtleID);
|
||||
const clearedCount = (turtle.pendingCommands || []).length;
|
||||
if (turtle) {
|
||||
const clearedCount = (turtle.pendingLegacyCommands || []).length;
|
||||
|
||||
if (clearedCount > 0) {
|
||||
console.log(`✅ Turtle ${turtleID} acknowledged ${clearedCount} command(s)`);
|
||||
turtle.pendingCommands = [];
|
||||
turtle.pendingLegacyCommands = [];
|
||||
}
|
||||
|
||||
res.json({ success: true, cleared: clearedCount });
|
||||
@@ -295,7 +387,7 @@ app.post('/api/turtle/:id/commands/ack', (req, res) => {
|
||||
// Get all turtles
|
||||
app.get('/api/turtles', (req, res) => {
|
||||
res.json({
|
||||
turtles: Array.from(turtleData.values())
|
||||
turtles: Array.from(turtles.values()).map(t => t.toJSON())
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,16 +407,15 @@ app.post('/api/turtle/:id/home', (req, res) => {
|
||||
// Persist to database
|
||||
db.saveTurtleHome(turtleID, position);
|
||||
|
||||
// Update turtle data
|
||||
if (turtleData.has(turtleID)) {
|
||||
const turtle = turtleData.get(turtleID);
|
||||
// Update turtle instance
|
||||
const turtle = turtles.get(turtleID);
|
||||
if (turtle) {
|
||||
turtle.homePosition = position;
|
||||
turtleData.set(turtleID, turtle);
|
||||
|
||||
// Broadcast update
|
||||
broadcastToClients({
|
||||
type: 'turtle_update',
|
||||
turtle: turtle
|
||||
turtle: turtle.toJSON()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -394,17 +485,24 @@ app.post('/api/turtle/:id/command', (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const { command, param } = req.body;
|
||||
const turtle = turtles.get(turtleID);
|
||||
|
||||
if (turtleData.has(turtleID)) {
|
||||
const turtle = turtleData.get(turtleID);
|
||||
turtle.pendingCommands = turtle.pendingCommands || [];
|
||||
turtle.pendingCommands.push({
|
||||
command,
|
||||
param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
|
||||
if (turtle) {
|
||||
// Check for state change commands
|
||||
if (command === 'set_state' || command === 'setState') {
|
||||
const stateName = param?.state || param;
|
||||
const stateData = param?.data || {};
|
||||
turtle.setState(stateName, stateData);
|
||||
console.log(`📤 State set for turtle ${turtleID}: ${stateName}`);
|
||||
} else {
|
||||
// Legacy command - queue for webbridge
|
||||
turtle.pendingLegacyCommands.push({
|
||||
command,
|
||||
param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Turtle not found' });
|
||||
@@ -415,6 +513,141 @@ app.post('/api/turtle/:id/command', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== EVAL PROTOCOL ENDPOINTS ==========
|
||||
|
||||
// Receive eval response from webbridge (turtle -> webbridge -> server)
|
||||
app.post('/api/turtle/eval-response', (req, res) => {
|
||||
try {
|
||||
const { turtleID, uuid, result, error } = req.body;
|
||||
|
||||
if (!turtleID || !uuid) {
|
||||
return res.status(400).json({ error: 'Missing turtleID or uuid' });
|
||||
}
|
||||
|
||||
const turtle = turtles.get(turtleID);
|
||||
if (turtle) {
|
||||
const handled = turtle.handleResponse(uuid, result, error);
|
||||
if (handled) {
|
||||
console.log(`📩 Eval response from T#${turtleID} uuid:${uuid.substring(0, 8)} - resolved`);
|
||||
} else {
|
||||
console.log(`📩 Eval response from T#${turtleID} uuid:${uuid.substring(0, 8)} - no matching pending command`);
|
||||
}
|
||||
} else {
|
||||
console.log(`📩 Eval response from unknown turtle ${turtleID}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing eval response:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set turtle state (state machine control)
|
||||
app.post('/api/turtle/:id/state', (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const { state, data } = req.body;
|
||||
|
||||
if (!state) {
|
||||
return res.status(400).json({ error: 'Missing state name' });
|
||||
}
|
||||
|
||||
const turtle = turtles.get(turtleID);
|
||||
if (turtle) {
|
||||
turtle.setState(state, data || {});
|
||||
console.log(`🔄 State set for turtle ${turtleID}: ${state}`);
|
||||
res.json({ success: true, state: turtle.stateName, description: turtle.stateDescription });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Turtle not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting state:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get turtle state
|
||||
app.get('/api/turtle/:id/state', (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const turtle = turtles.get(turtleID);
|
||||
|
||||
if (turtle) {
|
||||
res.json({
|
||||
state: turtle.stateName,
|
||||
description: turtle.stateDescription,
|
||||
turtleID,
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({ error: 'Turtle not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Execute Lua code on a turtle directly (for debugging/admin)
|
||||
app.post('/api/turtle/:id/exec', async (req, res) => {
|
||||
try {
|
||||
const turtleID = parseInt(req.params.id);
|
||||
const { code, timeout } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing code' });
|
||||
}
|
||||
|
||||
const turtle = turtles.get(turtleID);
|
||||
if (!turtle) {
|
||||
return res.status(404).json({ error: 'Turtle not found' });
|
||||
}
|
||||
|
||||
const result = await turtle.exec(code, timeout || 30000);
|
||||
res.json({ success: true, result });
|
||||
} catch (error) {
|
||||
console.error(`❌ Exec error for turtle ${req.params.id}:`, error.message);
|
||||
res.json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk blocks discovery (from webbridge batch uploads)
|
||||
app.post('/api/world/blocks/batch', (req, res) => {
|
||||
try {
|
||||
const { blocks, turtleID } = req.body;
|
||||
|
||||
if (!Array.isArray(blocks)) {
|
||||
return res.status(400).json({ error: 'blocks must be an array' });
|
||||
}
|
||||
|
||||
let stored = 0;
|
||||
for (const block of blocks) {
|
||||
if (block.x !== undefined && block.y !== undefined && block.z !== undefined && block.name) {
|
||||
storeBlock(block.x, block.y, block.z, { name: block.name, metadata: block.metadata || 0 }, turtleID || block.discoveredBy);
|
||||
stored++;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to web clients
|
||||
if (stored > 0) {
|
||||
broadcastToClients({
|
||||
type: 'blocks_discovered',
|
||||
blocks: blocks.filter(b => b.x !== undefined && b.name).map(b => ({
|
||||
x: b.x, y: b.y, z: b.z,
|
||||
name: b.name,
|
||||
metadata: b.metadata || 0,
|
||||
discoveredBy: turtleID || b.discoveredBy,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, stored });
|
||||
} catch (error) {
|
||||
console.error('❌ Error batch storing blocks:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== PATH RECORDING ENDPOINTS ==========
|
||||
|
||||
// Save a recorded path
|
||||
@@ -743,8 +976,8 @@ app.post('/api/mining-areas/:areaId/close', (req, res) => {
|
||||
app.get('/api/stats', (req, res) => {
|
||||
try {
|
||||
const stats = {
|
||||
activeTurtles: turtleData.size,
|
||||
turtles: turtleData.size,
|
||||
activeTurtles: turtles.size,
|
||||
turtles: turtles.size,
|
||||
totalBlocks: worldBlocks.size,
|
||||
blocks: worldBlocks.size,
|
||||
savedHomes: turtleHomes.size,
|
||||
@@ -936,14 +1169,19 @@ app.post('/api/groups/:groupId/command', (req, res) => {
|
||||
|
||||
let successCount = 0;
|
||||
for (const member of members) {
|
||||
if (turtleData.has(member.turtle_id)) {
|
||||
const turtle = turtleData.get(member.turtle_id);
|
||||
turtle.pendingCommands = turtle.pendingCommands || [];
|
||||
turtle.pendingCommands.push({
|
||||
command,
|
||||
param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
const turtle = turtles.get(member.turtle_id);
|
||||
if (turtle) {
|
||||
if (command === 'set_state' || command === 'setState') {
|
||||
const stateName = param?.state || param;
|
||||
const stateData = param?.data || {};
|
||||
turtle.setState(stateName, stateData);
|
||||
} else {
|
||||
turtle.pendingLegacyCommands.push({
|
||||
command,
|
||||
param,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
356
server/states/BaseState.js
Normal file
356
server/states/BaseState.js
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* BaseState - Abstract base class for turtle state machine
|
||||
*
|
||||
* Uses async generator pattern inspired by runi95/turtle-control-panel.
|
||||
* Each state implements *act() which yields control back periodically,
|
||||
* allowing the state machine to be cooperative and interruptible.
|
||||
*/
|
||||
export class BaseState {
|
||||
/**
|
||||
* @param {import('../Turtle.js').Turtle} turtle - The turtle instance
|
||||
* @param {Object} data - State-specific data for initialization/recovery
|
||||
*/
|
||||
constructor(turtle, data = {}) {
|
||||
this.turtle = turtle;
|
||||
this.data = data;
|
||||
this.cancelled = false;
|
||||
this.startedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of this state (used for logging and UI)
|
||||
*/
|
||||
get name() {
|
||||
return 'base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of what the state is doing
|
||||
*/
|
||||
get description() {
|
||||
return 'Idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The async generator that drives the state's behavior.
|
||||
* Subclasses MUST override this method.
|
||||
*
|
||||
* Yield to give up control temporarily (cooperative multitasking).
|
||||
* Return to signal that the state is complete.
|
||||
* Throw to signal an error.
|
||||
*
|
||||
* @yields {void}
|
||||
*/
|
||||
async *act() {
|
||||
// Base state does nothing - just idles
|
||||
while (!this.cancelled) {
|
||||
yield;
|
||||
await this._sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel this state. The act() generator should check this.cancelled
|
||||
* and clean up gracefully.
|
||||
*/
|
||||
cancel() {
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery data for persisting state across reconnections
|
||||
*/
|
||||
getRecoveryData() {
|
||||
return {
|
||||
stateName: this.name,
|
||||
data: this.data,
|
||||
startedAt: this.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Helper Methods for Subclasses ==========
|
||||
|
||||
/**
|
||||
* Execute a Lua command on the turtle and wait for the result
|
||||
*/
|
||||
async exec(luaCode) {
|
||||
return this.turtle.exec(luaCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle forward, digging if necessary
|
||||
*/
|
||||
async moveForward(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.dig(); return turtle.forward()');
|
||||
if (result) this.turtle.updatePositionForward();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.forward()');
|
||||
if (result) this.turtle.updatePositionForward();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle up, digging if necessary
|
||||
*/
|
||||
async moveUp(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.digUp(); return turtle.up()');
|
||||
if (result) this.turtle.updatePositionUp();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.up()');
|
||||
if (result) this.turtle.updatePositionUp();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle down, digging if necessary
|
||||
*/
|
||||
async moveDown(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.digDown(); return turtle.down()');
|
||||
if (result) this.turtle.updatePositionDown();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.down()');
|
||||
if (result) this.turtle.updatePositionDown();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the turtle to face a specific direction
|
||||
* @param {number} targetFacing - 0=North, 1=East, 2=South, 3=West
|
||||
*/
|
||||
async turnToFace(targetFacing) {
|
||||
const currentFacing = this.turtle.facing;
|
||||
if (currentFacing === targetFacing) return true;
|
||||
|
||||
const diff = (targetFacing - currentFacing + 4) % 4;
|
||||
|
||||
if (diff === 1) {
|
||||
await this.exec('return turtle.turnRight()');
|
||||
this.turtle.facing = targetFacing;
|
||||
} else if (diff === 2) {
|
||||
await this.exec('turtle.turnRight(); return turtle.turnRight()');
|
||||
this.turtle.facing = targetFacing;
|
||||
} else if (diff === 3) {
|
||||
await this.exec('return turtle.turnLeft()');
|
||||
this.turtle.facing = targetFacing;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to an adjacent point (one of the 6 neighbors)
|
||||
* Handles turning and digging automatically
|
||||
*/
|
||||
async moveToAdjacent(targetPoint) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return false;
|
||||
|
||||
const dx = targetPoint.x - pos.x;
|
||||
const dy = targetPoint.y - pos.y;
|
||||
const dz = targetPoint.z - pos.z;
|
||||
|
||||
if (dy === 1) return this.moveUp();
|
||||
if (dy === -1) return this.moveDown();
|
||||
|
||||
// Horizontal movement - determine facing
|
||||
let targetFacing;
|
||||
if (dx === 1) targetFacing = 1; // East
|
||||
else if (dx === -1) targetFacing = 3; // West
|
||||
else if (dz === 1) targetFacing = 2; // South
|
||||
else if (dz === -1) targetFacing = 0; // North
|
||||
else return false; // Not adjacent
|
||||
|
||||
await this.turnToFace(targetFacing);
|
||||
return this.moveForward();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a target point using the D* Lite pathfinder
|
||||
* This is a generator that yields after each step
|
||||
*/
|
||||
async *navigateTo(targetPoint, options = {}) {
|
||||
const { Point, DStarLite } = await import('../pathfinding/index.js');
|
||||
|
||||
const start = Point.from(this.turtle.position);
|
||||
const goal = Point.from(targetPoint);
|
||||
|
||||
if (!start || !goal) {
|
||||
console.error('Cannot navigate: missing position');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start.equals(goal)) return true;
|
||||
|
||||
const pathfinder = new DStarLite(start, goal, this.turtle.server.worldBlocks, {
|
||||
canMine: options.canMine !== false,
|
||||
maxSteps: options.maxSteps || 50000,
|
||||
});
|
||||
|
||||
let currentPos = start;
|
||||
let attempts = 0;
|
||||
const maxAttempts = options.maxAttempts || 5000;
|
||||
|
||||
while (!currentPos.equals(goal) && attempts < maxAttempts && !this.cancelled) {
|
||||
attempts++;
|
||||
|
||||
const nextStep = pathfinder.getNextStep();
|
||||
if (!nextStep) {
|
||||
console.log(`[${this.turtle.id}] No path to ${goal} from ${currentPos}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan surroundings before moving
|
||||
const scanResult = await this.exec(`
|
||||
local results = {}
|
||||
local hasBlock, data
|
||||
hasBlock, data = turtle.inspect()
|
||||
if hasBlock then results.forward = data end
|
||||
hasBlock, data = turtle.inspectUp()
|
||||
if hasBlock then results.up = data end
|
||||
hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then results.down = data end
|
||||
return results
|
||||
`);
|
||||
|
||||
// Update pathfinder with discovered blocks
|
||||
if (scanResult && typeof scanResult === 'object') {
|
||||
this.turtle.processScanResults(scanResult);
|
||||
// Update pathfinder with new information
|
||||
for (const [dir, blockData] of Object.entries(scanResult)) {
|
||||
const blockPos = this.turtle.getBlockPositionInDirection(dir);
|
||||
if (blockPos) {
|
||||
pathfinder.updateBlock(Point.from(blockPos), blockData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to move to the next step
|
||||
const success = await this.moveToAdjacent(nextStep);
|
||||
|
||||
if (success) {
|
||||
currentPos = Point.from(this.turtle.position);
|
||||
pathfinder.updateStart(currentPos);
|
||||
} else {
|
||||
// Movement failed - update the blocked node and replan
|
||||
pathfinder.updateBlock(nextStep, { name: 'minecraft:bedrock' }); // Treat as blocked
|
||||
if (!pathfinder.replan()) {
|
||||
console.log(`[${this.turtle.id}] Replanning failed - no path exists`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
yield; // Cooperative yield
|
||||
}
|
||||
|
||||
return currentPos.equals(goal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all 6 directions around the turtle
|
||||
*/
|
||||
async scanSurroundings() {
|
||||
const result = await this.exec(`
|
||||
local results = {}
|
||||
local facing = _G._turtleFacing or 0
|
||||
|
||||
-- Scan up/down
|
||||
local hasBlock, data
|
||||
hasBlock, data = turtle.inspectUp()
|
||||
if hasBlock then results.up = {name=data.name, metadata=data.metadata or 0} end
|
||||
hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then results.down = {name=data.name, metadata=data.metadata or 0} end
|
||||
|
||||
-- Scan all 4 horizontal directions
|
||||
for i = 0, 3 do
|
||||
hasBlock, data = turtle.inspect()
|
||||
if hasBlock then
|
||||
local dir = (facing + i) % 4
|
||||
results["h" .. dir] = {name=data.name, metadata=data.metadata or 0}
|
||||
end
|
||||
if i < 3 then turtle.turnRight(); facing = (facing + 1) % 4 end
|
||||
end
|
||||
-- Turn back to original facing
|
||||
turtle.turnRight()
|
||||
facing = (facing + 1) % 4
|
||||
_G._turtleFacing = facing
|
||||
|
||||
return results
|
||||
`);
|
||||
|
||||
if (result) {
|
||||
this.turtle.processScanResults(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check fuel level
|
||||
*/
|
||||
async checkFuel() {
|
||||
const fuel = await this.exec('return turtle.getFuelLevel()');
|
||||
if (fuel !== null) this.turtle.fuel = fuel;
|
||||
return fuel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory contents
|
||||
*/
|
||||
async getInventory() {
|
||||
const inv = await this.exec(`
|
||||
local items = {}
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
items[slot] = {name=item.name, count=item.count, damage=item.damage}
|
||||
end
|
||||
end
|
||||
return items
|
||||
`);
|
||||
if (inv) this.turtle.inventory = inv;
|
||||
return inv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if inventory is full
|
||||
*/
|
||||
async isInventoryFull() {
|
||||
const result = await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) == 0 then return false end
|
||||
end
|
||||
return true
|
||||
`);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to refuel from inventory
|
||||
*/
|
||||
async tryRefuel() {
|
||||
return this.exec(`
|
||||
for slot = 1, 16 do
|
||||
turtle.select(slot)
|
||||
if turtle.refuel(0) then
|
||||
turtle.refuel(1)
|
||||
turtle.select(1)
|
||||
return true
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
return false
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a duration (non-blocking)
|
||||
*/
|
||||
_sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
89
server/states/DumpInventoryState.js
Normal file
89
server/states/DumpInventoryState.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* DumpInventoryState - Dump inventory into nearby containers
|
||||
* Keeps fuel items, dumps everything else
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const KEEP_ITEMS = new Set([
|
||||
'minecraft:coal', 'minecraft:charcoal', 'minecraft:coal_block',
|
||||
'minecraft:torch', 'minecraft:lava_bucket',
|
||||
]);
|
||||
|
||||
export class DumpInventoryState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.returnState = data.returnState || null;
|
||||
this.returnData = data.returnData || {};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'dumping';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return 'Dumping inventory';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting inventory dump`);
|
||||
|
||||
// Try to dump into containers in all directions
|
||||
const keepItems = [...KEEP_ITEMS].map(n => `"${n}"`).join(', ');
|
||||
|
||||
const result = await this.exec(`
|
||||
local keepItems = {${keepItems}}
|
||||
local keepSet = {}
|
||||
for _, name in ipairs(keepItems) do keepSet[name] = true end
|
||||
|
||||
local dropFns = {
|
||||
turtle.drop,
|
||||
turtle.dropUp,
|
||||
turtle.dropDown,
|
||||
}
|
||||
|
||||
local dumpedCount = 0
|
||||
|
||||
-- Try each direction
|
||||
for _, dropFn in ipairs(dropFns) do
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and not keepSet[item.name] then
|
||||
turtle.select(slot)
|
||||
if dropFn() then
|
||||
dumpedCount = dumpedCount + item.count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
turtle.select(1)
|
||||
return {dumped = dumpedCount}
|
||||
`);
|
||||
|
||||
if (result) {
|
||||
console.log(`[${this.turtle.id}] Dumped ${result.dumped} items`);
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Update inventory after dumping
|
||||
await this.getInventory();
|
||||
|
||||
// Transition to next state
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
177
server/states/ExploringState.js
Normal file
177
server/states/ExploringState.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* ExploringState - Autonomous exploration to discover the world map
|
||||
* Similar to mining but focused on discovering blocks rather than mining them
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class ExploringState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.maxDistance = data.maxDistance || 200;
|
||||
this.minFuel = data.minFuel || 500;
|
||||
this.blocksDiscovered = 0;
|
||||
this.stuckCounter = 0;
|
||||
this.visitedPositions = new Set();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'exploring';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Exploring - ${this.blocksDiscovered} blocks discovered`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting exploration`);
|
||||
|
||||
while (!this.cancelled) {
|
||||
// Safety checks
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check distance
|
||||
if (this._isTooFar()) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Mark position
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
||||
}
|
||||
|
||||
// Comprehensive scan
|
||||
const scanResult = await this.scanSurroundings();
|
||||
if (scanResult) {
|
||||
this.blocksDiscovered += Object.keys(scanResult).length;
|
||||
}
|
||||
|
||||
// Mine any valuable ores we find
|
||||
yield* this._checkAndMineOres();
|
||||
|
||||
// Exploration movement
|
||||
yield* this._exploreStep();
|
||||
|
||||
yield;
|
||||
await this._sleep(300);
|
||||
}
|
||||
}
|
||||
|
||||
async *_checkAndMineOres() {
|
||||
const valuableOres = new Set([
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore',
|
||||
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
|
||||
]);
|
||||
|
||||
const directions = [
|
||||
{ check: 'turtle.inspect()', dig: 'turtle.dig()' },
|
||||
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()' },
|
||||
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()' },
|
||||
];
|
||||
|
||||
for (const { check, dig } of directions) {
|
||||
if (this.cancelled) return;
|
||||
const result = await this.exec(`
|
||||
local hasBlock, data = ${check}
|
||||
if hasBlock then return {name = data.name} end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && valuableOres.has(result.name)) {
|
||||
await this.exec(dig);
|
||||
this.turtle.emit('blockMined', { blockType: result.name });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.stuckCounter > 6) {
|
||||
await this.moveUp(true);
|
||||
this.stuckCounter = 0;
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
_isTooFar() {
|
||||
const pos = this.turtle.position;
|
||||
const home = this.turtle.homePosition;
|
||||
if (!pos || !home) return false;
|
||||
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
|
||||
return dist > this.maxDistance;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
maxDistance: this.maxDistance,
|
||||
minFuel: this.minFuel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
124
server/states/FarmingState.js
Normal file
124
server/states/FarmingState.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* FarmingState - Automated farming (plant, harvest, replant)
|
||||
* Works with wheat, carrots, potatoes, beetroot, etc.
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const CROP_SEEDS = {
|
||||
'minecraft:wheat': 'minecraft:wheat_seeds',
|
||||
'minecraft:carrots': 'minecraft:carrot',
|
||||
'minecraft:potatoes': 'minecraft:potato',
|
||||
'minecraft:beetroots': 'minecraft:beetroot_seeds',
|
||||
};
|
||||
|
||||
export class FarmingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.area = data.area || null; // {minX, minZ, maxX, maxZ, y}
|
||||
this.harvestCount = 0;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'farming';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Farming - ${this.harvestCount} harvested`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting farming operation`);
|
||||
|
||||
if (!this.area) {
|
||||
console.log(`[${this.turtle.id}] No farming area defined`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Farm in a zigzag pattern over the area
|
||||
const { minX, minZ, maxX, maxZ, y } = this.area;
|
||||
let direction = 1; // 1 = positive Z, -1 = negative Z
|
||||
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const zStart = direction === 1 ? minZ : maxZ;
|
||||
const zEnd = direction === 1 ? maxZ : minZ;
|
||||
const zStep = direction;
|
||||
|
||||
for (let z = zStart; direction === 1 ? z <= zEnd : z >= zEnd; z += zStep) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
// Navigate to position above the crop
|
||||
const target = { x, y: y + 1, z };
|
||||
const navGen = this.navigateTo(target);
|
||||
for await (const _ of navGen) {
|
||||
if (this.cancelled) return;
|
||||
yield;
|
||||
}
|
||||
|
||||
// Check the block below
|
||||
const blockBelow = await this.exec(`
|
||||
local hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then
|
||||
return {name = data.name, state = data.state}
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (blockBelow) {
|
||||
// Check if crop is mature (age = 7 for most crops)
|
||||
const isMature = blockBelow.state && blockBelow.state.age === 7;
|
||||
|
||||
if (isMature) {
|
||||
// Harvest
|
||||
await this.exec('turtle.digDown()');
|
||||
this.harvestCount++;
|
||||
|
||||
// Replant
|
||||
const seedName = CROP_SEEDS[blockBelow.name];
|
||||
if (seedName) {
|
||||
await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == "${seedName}" then
|
||||
turtle.select(slot)
|
||||
turtle.placeDown()
|
||||
turtle.select(1)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
direction *= -1; // Zigzag
|
||||
}
|
||||
|
||||
// Done farming one pass - check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
this.turtle.setState('goHome', {
|
||||
reason: 'inventory_full',
|
||||
returnState: 'farming',
|
||||
returnData: this.data
|
||||
});
|
||||
} else {
|
||||
// Start another pass
|
||||
console.log(`[${this.turtle.id}] Farm pass complete, ${this.harvestCount} harvested`);
|
||||
yield* this.act(); // Loop
|
||||
}
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
area: this.area,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
89
server/states/GoHomeState.js
Normal file
89
server/states/GoHomeState.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* GoHomeState - Navigate the turtle back to its home position
|
||||
* After arrival, optionally transitions to dump inventory or another state
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class GoHomeState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.reason = data.reason || 'manual'; // 'low_fuel', 'inventory_full', 'too_far', 'manual'
|
||||
this.returnState = data.returnState || null; // State to return to after going home
|
||||
this.returnData = data.returnData || {};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'returning';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Returning home (${this.reason})`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
const home = this.turtle.homePosition;
|
||||
if (!home) {
|
||||
console.log(`[${this.turtle.id}] No home position set, going idle`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Going home: reason=${this.reason}`);
|
||||
|
||||
// Navigate to home using D* Lite
|
||||
const navGen = this.navigateTo(home, { canMine: true });
|
||||
for await (const _ of navGen) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
// Check fuel during return
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < 100) {
|
||||
// Emergency refuel
|
||||
await this.tryRefuel();
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
// Arrived home
|
||||
console.log(`[${this.turtle.id}] Arrived home`);
|
||||
|
||||
// If we came home to dump inventory, do so
|
||||
if (this.reason === 'inventory_full') {
|
||||
this.turtle.setState('dumpInventory', {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If we came home to refuel, try refueling
|
||||
if (this.reason === 'low_fuel') {
|
||||
this.turtle.setState('refueling', {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's a return state, go to it
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: go idle
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
reason: this.reason,
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
24
server/states/IdleState.js
Normal file
24
server/states/IdleState.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* IdleState - Turtle is idle, waiting for commands
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class IdleState extends BaseState {
|
||||
get name() {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return 'Idle - waiting for commands';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
// Send periodic status updates while idle
|
||||
while (!this.cancelled) {
|
||||
await this.checkFuel();
|
||||
await this.scanSurroundings();
|
||||
yield;
|
||||
await this._sleep(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
221
server/states/MiningState.js
Normal file
221
server/states/MiningState.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* MiningState - Autonomous mining with intelligent exploration
|
||||
* Mines ores, explores caves, manages inventory
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const VALUABLE_BLOCKS = new Set([
|
||||
'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:gold_ore',
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:redstone_ore',
|
||||
'minecraft:lapis_ore', 'minecraft:copper_ore',
|
||||
'minecraft:deepslate_coal_ore', 'minecraft:deepslate_iron_ore',
|
||||
'minecraft:deepslate_gold_ore', 'minecraft:deepslate_diamond_ore',
|
||||
'minecraft:deepslate_emerald_ore', 'minecraft:deepslate_redstone_ore',
|
||||
'minecraft:deepslate_lapis_ore', 'minecraft:deepslate_copper_ore',
|
||||
'minecraft:nether_gold_ore', 'minecraft:nether_quartz_ore',
|
||||
'minecraft:ancient_debris',
|
||||
]);
|
||||
|
||||
export class MiningState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.maxDistance = data.maxDistance || 200;
|
||||
this.minFuel = data.minFuel || 500;
|
||||
this.blocksMined = 0;
|
||||
this.oresFound = 0;
|
||||
this.stuckCounter = 0;
|
||||
this.visitedPositions = new Set();
|
||||
this.miningArea = data.miningArea || null; // Optional bounded area
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'mining';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Mining - ${this.blocksMined} mined, ${this.oresFound} ores found`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting mining operation`);
|
||||
|
||||
while (!this.cancelled) {
|
||||
// Safety checks
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
console.log(`[${this.turtle.id}] Cannot refuel, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'mining', returnData: this.data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check distance from home
|
||||
if (this._isTooFar()) {
|
||||
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark current position as visited
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
||||
}
|
||||
|
||||
// Scan surroundings for ores
|
||||
const scanResult = await this.scanSurroundings();
|
||||
|
||||
// Mine any ores found in surroundings
|
||||
yield* this._mineAdjacentOres();
|
||||
|
||||
// Explore step - try to find new areas
|
||||
yield* this._exploreStep();
|
||||
|
||||
yield;
|
||||
await this._sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine ores in adjacent positions
|
||||
*/
|
||||
async *_mineAdjacentOres() {
|
||||
// Check all 6 directions for ores
|
||||
const directions = [
|
||||
{ check: 'turtle.inspect()', dig: 'turtle.dig()', dir: 'forward' },
|
||||
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()', dir: 'up' },
|
||||
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()', dir: 'down' },
|
||||
];
|
||||
|
||||
for (const { check, dig, dir } of directions) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
const result = await this.exec(`
|
||||
local hasBlock, data = ${check}
|
||||
if hasBlock then
|
||||
return {name = data.name, metadata = data.metadata or 0}
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && VALUABLE_BLOCKS.has(result.name)) {
|
||||
console.log(`[${this.turtle.id}] Found ore: ${result.name} (${dir})`);
|
||||
await this.exec(dig);
|
||||
this.blocksMined++;
|
||||
this.oresFound++;
|
||||
|
||||
// Report mined block to server
|
||||
this.turtle.emit('blockMined', { blockType: result.name, direction: dir });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore step - move to unvisited positions, favor horizontal movement
|
||||
*/
|
||||
async *_exploreStep() {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const r = Math.random() * 100;
|
||||
|
||||
// 75%: horizontal exploration
|
||||
if (r < 75) {
|
||||
// Try to find an unvisited forward direction
|
||||
let moved = false;
|
||||
|
||||
// Try current facing first
|
||||
const forwardPos = this.turtle.getBlockPositionInDirection('forward');
|
||||
if (forwardPos && !this.visitedPositions.has(`${forwardPos.x},${forwardPos.y},${forwardPos.z}`)) {
|
||||
const success = await this.moveForward(true);
|
||||
if (success) {
|
||||
this.stuckCounter = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If forward is visited or blocked, try other directions
|
||||
if (!moved) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await this.exec('turtle.turnRight()');
|
||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
||||
|
||||
const newForwardPos = this.turtle.getBlockPositionInDirection('forward');
|
||||
if (newForwardPos && !this.visitedPositions.has(`${newForwardPos.x},${newForwardPos.y},${newForwardPos.z}`)) {
|
||||
const success = await this.moveForward(true);
|
||||
if (success) {
|
||||
this.stuckCounter = 0;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
// All directions visited, just move forward
|
||||
const success = await this.moveForward(true);
|
||||
if (!success) {
|
||||
this.stuckCounter++;
|
||||
await this.exec('turtle.turnRight()');
|
||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
||||
}
|
||||
}
|
||||
|
||||
// 15%: go down (if not too deep)
|
||||
} else if (r < 90 && pos.y > -50) {
|
||||
const downPos = { x: pos.x, y: pos.y - 1, z: pos.z };
|
||||
if (!this.visitedPositions.has(`${downPos.x},${downPos.y},${downPos.z}`)) {
|
||||
await this.moveDown(true);
|
||||
}
|
||||
|
||||
// 10%: go up
|
||||
} else {
|
||||
const upPos = { x: pos.x, y: pos.y + 1, z: pos.z };
|
||||
if (!this.visitedPositions.has(`${upPos.x},${upPos.y},${upPos.z}`)) {
|
||||
await this.moveUp(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle being stuck
|
||||
if (this.stuckCounter > 5) {
|
||||
console.log(`[${this.turtle.id}] Stuck! Trying to escape`);
|
||||
await this.moveUp(true);
|
||||
this.stuckCounter = 0;
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
_isTooFar() {
|
||||
const pos = this.turtle.position;
|
||||
const home = this.turtle.homePosition;
|
||||
if (!pos || !home) return false;
|
||||
|
||||
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
|
||||
return dist > this.maxDistance;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
maxDistance: this.maxDistance,
|
||||
minFuel: this.minFuel,
|
||||
miningArea: this.miningArea,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
78
server/states/MovingState.js
Normal file
78
server/states/MovingState.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* MovingState - Navigate the turtle to a target position using D* Lite
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class MovingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.target = data.target; // {x, y, z}
|
||||
this.onArrival = data.onArrival || null; // Callback/next state when arrived
|
||||
this.canMine = data.canMine !== false;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'moving';
|
||||
}
|
||||
|
||||
get description() {
|
||||
const pos = this.target;
|
||||
if (pos) {
|
||||
return `Moving to (${pos.x}, ${pos.y}, ${pos.z})`;
|
||||
}
|
||||
return 'Moving to target';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
if (!this.target) {
|
||||
console.log(`[${this.turtle.id}] MovingState: No target set`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Moving to ${this.target.x},${this.target.y},${this.target.z}`);
|
||||
|
||||
// Use the navigateTo generator from BaseState
|
||||
const nav = this.navigateTo(this.target, { canMine: this.canMine });
|
||||
|
||||
for await (const _ of nav) {
|
||||
if (this.cancelled) return;
|
||||
yield;
|
||||
}
|
||||
|
||||
// Check if we actually arrived
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
const { Point } = await import('../pathfinding/index.js');
|
||||
const current = Point.from(pos);
|
||||
const goal = Point.from(this.target);
|
||||
|
||||
if (current.distanceTo(goal) <= 1) {
|
||||
console.log(`[${this.turtle.id}] Arrived at destination`);
|
||||
|
||||
if (this.onArrival === 'idle') {
|
||||
this.turtle.setState('idle');
|
||||
} else if (this.onArrival === 'mine') {
|
||||
this.turtle.setState('mining', this.data);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Failed to reach destination`);
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
target: this.target,
|
||||
onArrival: this.onArrival,
|
||||
canMine: this.canMine,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
126
server/states/RefuelingState.js
Normal file
126
server/states/RefuelingState.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* RefuelingState - Refuel the turtle from inventory or nearby containers
|
||||
* Prioritizes fuel sources: lava buckets > coal blocks > coal > logs > sticks
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const FUEL_PRIORITY = [
|
||||
'minecraft:lava_bucket',
|
||||
'minecraft:coal_block',
|
||||
'minecraft:charcoal',
|
||||
'minecraft:coal',
|
||||
'minecraft:oak_log', 'minecraft:birch_log', 'minecraft:spruce_log',
|
||||
'minecraft:dark_oak_log', 'minecraft:jungle_log', 'minecraft:acacia_log',
|
||||
'minecraft:mangrove_log', 'minecraft:cherry_log',
|
||||
'minecraft:oak_planks', 'minecraft:birch_planks', 'minecraft:spruce_planks',
|
||||
'minecraft:stick',
|
||||
];
|
||||
|
||||
export class RefuelingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.returnState = data.returnState || null;
|
||||
this.returnData = data.returnData || {};
|
||||
this.targetFuel = data.targetFuel || 5000;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'refueling';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return 'Refueling turtle';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting refueling, target: ${this.targetFuel}`);
|
||||
|
||||
// First try to refuel from own inventory using priority order
|
||||
const refueled = await this.exec(`
|
||||
local fuelPriority = ${this._luaFuelTable()}
|
||||
local targetFuel = ${this.targetFuel}
|
||||
local refueled = false
|
||||
|
||||
for _, fuelName in ipairs(fuelPriority) do
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == fuelName then
|
||||
turtle.select(slot)
|
||||
turtle.refuel()
|
||||
refueled = true
|
||||
if turtle.getFuelLevel() >= targetFuel then
|
||||
turtle.select(1)
|
||||
return {success = true, fuel = turtle.getFuelLevel()}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
turtle.select(1)
|
||||
return {success = refueled, fuel = turtle.getFuelLevel()}
|
||||
`);
|
||||
|
||||
if (refueled) {
|
||||
this.turtle.fuel = refueled.fuel;
|
||||
console.log(`[${this.turtle.id}] Refueled to ${refueled.fuel}`);
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Try to refuel from nearby containers (chests, barrels)
|
||||
if (!refueled || !refueled.success || refueled.fuel < this.targetFuel) {
|
||||
console.log(`[${this.turtle.id}] Trying nearby containers for fuel`);
|
||||
|
||||
const containerResult = await this.exec(`
|
||||
local directions = {"front", "top", "bottom"}
|
||||
local suckFns = {turtle.suck, turtle.suckUp, turtle.suckDown}
|
||||
|
||||
for i, dir in ipairs(directions) do
|
||||
-- Try to suck items from container
|
||||
for attempt = 1, 16 do
|
||||
if suckFns[i]() then
|
||||
-- Check if it's fuel
|
||||
local slot = turtle.getSelectedSlot()
|
||||
if turtle.refuel(0) then
|
||||
turtle.refuel()
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {fuel = turtle.getFuelLevel()}
|
||||
`);
|
||||
|
||||
if (containerResult) {
|
||||
this.turtle.fuel = containerResult.fuel;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Transition to next state
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
_luaFuelTable() {
|
||||
const items = FUEL_PRIORITY.map(name => `"${name}"`).join(', ');
|
||||
return `{${items}}`;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
targetFuel: this.targetFuel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
9
server/states/index.js
Normal file
9
server/states/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export { BaseState } from './BaseState.js';
|
||||
export { IdleState } from './IdleState.js';
|
||||
export { MovingState } from './MovingState.js';
|
||||
export { MiningState } from './MiningState.js';
|
||||
export { ExploringState } from './ExploringState.js';
|
||||
export { GoHomeState } from './GoHomeState.js';
|
||||
export { RefuelingState } from './RefuelingState.js';
|
||||
export { DumpInventoryState } from './DumpInventoryState.js';
|
||||
export { FarmingState } from './FarmingState.js';
|
||||
1169
turtle.lua
1169
turtle.lua
File diff suppressed because it is too large
Load Diff
@@ -337,43 +337,44 @@ while true do
|
||||
-- Forward commands back to turtle
|
||||
for _, cmd in ipairs(commands) do
|
||||
stats.commandsSent = stats.commandsSent + 1
|
||||
addLog(" CMD: " .. cmd.command .. " -> Turtle #" .. turtleID, colors.yellow)
|
||||
|
||||
local commandPacket = {
|
||||
command = cmd.command,
|
||||
param = cmd.param,
|
||||
target = turtleID
|
||||
}
|
||||
local commandPacket
|
||||
|
||||
print("📡 Transmitting command to Turtle #" .. turtleID)
|
||||
print(" Channel: " .. COMMAND_CHANNEL)
|
||||
print(" Command: " .. cmd.command)
|
||||
print(" Target: " .. turtleID)
|
||||
print(" Packet: " .. textutils.serialize(commandPacket))
|
||||
if cmd.type == "eval" then
|
||||
-- New eval protocol command
|
||||
commandPacket = {
|
||||
type = "eval",
|
||||
uuid = cmd.uuid,
|
||||
code = cmd.code,
|
||||
target = turtleID,
|
||||
stateUpdate = cmd.stateUpdate
|
||||
}
|
||||
addLog(" EVAL: " .. (cmd.uuid or "?"):sub(1, 8) .. " -> T#" .. turtleID, colors.yellow)
|
||||
else
|
||||
-- Legacy command protocol
|
||||
commandPacket = {
|
||||
command = cmd.command,
|
||||
param = cmd.param,
|
||||
target = turtleID
|
||||
}
|
||||
addLog(" CMD: " .. cmd.command .. " -> T#" .. turtleID, colors.yellow)
|
||||
end
|
||||
|
||||
-- Send command multiple times for reliability
|
||||
for i = 1, 3 do
|
||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, commandPacket)
|
||||
print(" Transmission " .. i .. "/3 sent")
|
||||
os.sleep(0.05) -- Small delay between retransmissions
|
||||
end
|
||||
|
||||
-- Debug: show what we're sending
|
||||
if not hasMonitor then
|
||||
print(" ✅ Sent 3 times on channel " .. COMMAND_CHANNEL)
|
||||
os.sleep(0.05)
|
||||
end
|
||||
end
|
||||
|
||||
-- Acknowledge that we sent the commands
|
||||
-- Wait longer to ensure turtle has received them
|
||||
os.sleep(1.5) -- Give turtle time to receive and process
|
||||
os.sleep(1.5)
|
||||
if acknowledgeCommands(turtleID) then
|
||||
addLog(" ACK: Commands acknowledged", colors.lime)
|
||||
else
|
||||
addLog(" WARN: Failed to acknowledge", colors.orange)
|
||||
end
|
||||
|
||||
-- Mark that we sent commands to this turtle
|
||||
recentCommandSends[turtleID] = os.epoch("utc")
|
||||
end
|
||||
end
|
||||
@@ -510,6 +511,33 @@ while true do
|
||||
addLog(" -> Server error, local only", colors.red)
|
||||
end
|
||||
|
||||
elseif message.type == "eval_response" then
|
||||
-- Turtle eval response - forward to server
|
||||
local turtleID = message.turtleID
|
||||
local uuid = message.uuid
|
||||
addLog("Eval response from T#" .. turtleID .. " uuid:" .. (uuid or "?"):sub(1, 8), colors.cyan)
|
||||
|
||||
local success = pcall(function()
|
||||
local response = http.post(
|
||||
SERVER_URL .. "/api/turtle/eval-response",
|
||||
textutils.serializeJSON({
|
||||
turtleID = turtleID,
|
||||
uuid = uuid,
|
||||
result = message.result,
|
||||
error = message.error
|
||||
}),
|
||||
{["Content-Type"] = "application/json"}
|
||||
)
|
||||
if response then
|
||||
response.close()
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
stats.errors = stats.errors + 1
|
||||
addLog(" -> Failed to forward eval response", colors.red)
|
||||
end
|
||||
|
||||
elseif message.type == "blocks_discovered" then
|
||||
-- Turtle discovered new blocks - forward to server
|
||||
local turtleID = message.turtleID
|
||||
|
||||
Reference in New Issue
Block a user