Compare commits

...

23 Commits

Author SHA1 Message Date
MayaTheShy
dca14643c6 feat: Refactor TurtleCard and TurtleDetails to implement active state management and enhance command handling 2026-02-20 02:03:47 -05:00
MayaTheShy
67498b93bf feat: Add setTurtleState and execOnTurtle functions for REST API integration 2026-02-20 02:02:40 -05:00
MayaTheShy
df00a6be70 feat: Add handling for 'blocks_discovered' message to update worldBlocks in turtle store 2026-02-20 02:02:04 -05:00
MayaTheShy
f23ebcb05a feat: Enhance multi-face texture handling for Minecraft blocks in Map3D component 2026-02-20 02:01:53 -05:00
MayaTheShy
08281d88fe feat: Expand texture mapping for Minecraft blocks and enhance multi-face block handling 2026-02-20 02:01:00 -05:00
MayaTheShy
882dc75762 feat: Add state management and modes for turtle commands in Pocket Computer 2026-02-20 01:59:37 -05:00
MayaTheShy
4c774ac306 feat: Enhance turtle command handling with legacy support and eval response endpoints 2026-02-20 01:57:09 -05:00
MayaTheShy
e38551dfc2 feat: Update command polling endpoint to support combined eval and legacy commands for turtles 2026-02-20 01:54:59 -05:00
MayaTheShy
87c103ea9e feat: Update WebSocket handling for turtle commands and status updates, transitioning to Turtle instance methods for state management 2026-02-20 01:54:43 -05:00
MayaTheShy
836e7b61c7 feat: Enhance turtle instance management with improved state handling and event forwarding 2026-02-20 01:54:15 -05:00
MayaTheShy
f8baadce3a feat: Implement eval command protocol and response handling in web bridge 2026-02-20 01:53:43 -05:00
MayaTheShy
a5aec5800e Refactor turtle.lua for v4: Implement eval/response protocol, streamline state management, enhance GPS and movement functions, and improve command handling. 2026-02-20 01:50:55 -05:00
MayaTheShy
0e41ee12c5 feat: Add Turtle class to manage state machine and command execution for server-side turtles 2026-02-20 01:46:39 -05:00
MayaTheShy
bdc4dade9f feat: Implement RefuelingState class for turtle state machine to manage fuel replenishment 2026-02-20 01:46:09 -05:00
MayaTheShy
e8ef1d669b feat: Implement MiningState class for turtle state machine to autonomously mine and explore 2026-02-20 01:46:05 -05:00
MayaTheShy
3dfc4237c6 feat: Add index.js to export state classes for turtle state machine 2026-02-20 01:46:01 -05:00
MayaTheShy
1492f59de7 feat: Implement GoHomeState class for turtle state machine to navigate back to home position 2026-02-20 01:45:57 -05:00
MayaTheShy
d2185ac493 feat: Implement FarmingState class for turtle state machine to automate farming operations 2026-02-20 01:45:46 -05:00
MayaTheShy
fa98e86055 feat: Implement ExploringState class for turtle state machine to autonomously explore and discover the world map 2026-02-20 01:45:39 -05:00
MayaTheShy
1b08777f88 feat: Implement DumpInventoryState class for turtle state machine to manage inventory dumping 2026-02-20 01:45:29 -05:00
MayaTheShy
68d4ee52c9 feat: Implement MovingState class for turtle state machine to navigate to target positions 2026-02-20 01:42:16 -05:00
MayaTheShy
6f73fdd0ed feat: Implement IdleState class for turtle state machine to handle idle behavior 2026-02-20 01:42:06 -05:00
MayaTheShy
0f22c8e49b feat: Add BaseState class for turtle state machine with async generator support 2026-02-20 01:41:58 -05:00
18 changed files with 2875 additions and 1003 deletions

View File

@@ -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"

View File

@@ -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 */}

View File

@@ -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);

View File

@@ -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
View 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);
}
}

View File

@@ -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
View 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));
}
}

View 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,
},
};
}
}

View 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,
},
};
}
}

View 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,
},
};
}
}

View 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,
},
};
}
}

View 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);
}
}
}

View 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,
},
};
}
}

View 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,
},
};
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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