Files
remoteturtle/client/src/store/turtleStore.js

519 lines
17 KiB
JavaScript

import { create } from 'zustand';
// Use environment variables or fallback to relative URLs for proxy
const WS_URL = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const API_URL = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
console.log('🔌 WebSocket URL:', WS_URL);
console.log('📡 API URL:', API_URL);
export const useTurtleStore = create((set, get) => ({
// State
turtles: {},
players: {},
worldBlocks: [],
chunkAnalyses: {},
selectedTurtleId: null,
connected: false,
ws: null,
// WebSocket connection
connect: () => {
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('✅ Connected to server');
set({ connected: true, ws });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'initial_state') {
const turtlesMap = {};
data.turtles.forEach(turtle => {
turtlesMap[turtle.turtleID] = turtle;
});
const playersMap = {};
if (data.players && Array.isArray(data.players)) {
data.players.forEach(player => {
playersMap[player.playerID] = player;
});
}
set({
turtles: turtlesMap,
players: playersMap,
worldBlocks: data.blocks || []
});
} else if (data.type === 'turtle_update') {
set(state => ({
turtles: {
...state.turtles,
[data.turtle.turtleID]: data.turtle
}
}));
// Update world blocks from turtle surroundings
if (data.turtle.surroundings && data.turtle.position && data.turtle.facing !== undefined) {
get().updateBlocksFromSurroundings(data.turtle);
}
} else if (data.type === 'turtle_removed' || data.type === 'turtle_disconnected') {
console.log(`🔌 Turtle ${data.turtleID} removed`);
set(state => {
const newTurtles = { ...state.turtles };
delete newTurtles[data.turtleID];
// If the removed turtle was selected, deselect it
const newSelectedId = state.selectedTurtleId === data.turtleID
? null
: state.selectedTurtleId;
return {
turtles: newTurtles,
selectedTurtleId: newSelectedId
};
});
} else if (data.type === 'player_update') {
set(state => ({
players: {
...state.players,
[data.playerID]: {
playerID: data.playerID,
position: data.position,
label: data.label || null,
timestamp: data.timestamp || Date.now()
}
}
}));
} else if (data.type === 'block_discovered') {
if (data.block) {
set(state => {
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
const key = `${data.block.x},${data.block.y},${data.block.z}`;
blockMap.set(key, data.block);
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()) };
});
}
} else if (data.type === 'turtle_event') {
// Handle live turtle events (inventory, peripherals)
const turtleId = data.turtleID;
if (turtleId && data.eventType === 'inventory_update' && data.inventory) {
set(state => {
const turtle = state.turtles[turtleId];
if (!turtle) return state;
return {
turtles: {
...state.turtles,
[turtleId]: { ...turtle, inventory: data.inventory }
}
};
});
} else if (turtleId && (data.eventType === 'peripheral_attached' || data.eventType === 'peripheral_detached')) {
set(state => {
const turtle = state.turtles[turtleId];
if (!turtle) return state;
return {
turtles: {
...state.turtles,
[turtleId]: { ...turtle, peripherals: data.peripherals || turtle.peripherals }
}
};
});
}
} else if (data.type === 'chunk_analysis') {
// Store chunk analysis results
if (data.chunk) {
set(state => ({
chunkAnalyses: {
...state.chunkAnalyses,
[`${data.chunk.x},${data.chunk.z}`]: data.chunk
}
}));
}
} else if (data.type === 'block_deleted') {
// Remove a block from the world map (turtle moved into it)
if (data.x !== undefined && data.y !== undefined && data.z !== undefined) {
set(state => {
const key = `${data.x},${data.y},${data.z}`;
return {
worldBlocks: state.worldBlocks.filter(b => `${b.x},${b.y},${b.z}` !== key)
};
});
}
}
} catch (error) {
console.error('Error processing message:', error);
}
};
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
// Attempt reconnect after 3 seconds
setTimeout(() => {
get().connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
},
// Actions
selectTurtle: (turtleId) => {
set({ selectedTurtleId: turtleId });
},
updateBlocksFromSurroundings: (turtle) => {
const { surroundings, position, facing } = turtle;
if (!surroundings || !position || facing === undefined) return;
const newBlocks = [];
const blockMap = new Map(get().worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
// Calculate block positions based on turtle position and facing
const directions = {
forward: { x: 0, y: 0, z: 0 },
up: { x: 0, y: 1, z: 0 },
down: { x: 0, y: -1, z: 0 }
};
// Facing: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X)
if (surroundings.forward) {
if (facing === 0) directions.forward = { x: 0, y: 0, z: -1 };
else if (facing === 1) directions.forward = { x: 1, y: 0, z: 0 };
else if (facing === 2) directions.forward = { x: 0, y: 0, z: 1 };
else if (facing === 3) directions.forward = { x: -1, y: 0, z: 0 };
}
Object.entries(surroundings).forEach(([direction, blockData]) => {
const offset = directions[direction];
if (!offset) return;
const blockPos = {
x: position.x + offset.x,
y: position.y + offset.y,
z: position.z + offset.z
};
const key = `${blockPos.x},${blockPos.y},${blockPos.z}`;
if (!blockMap.has(key)) {
blockMap.set(key, {
...blockPos,
...blockData,
discoveredBy: turtle.turtleID,
timestamp: Date.now()
});
}
});
set({ worldBlocks: Array.from(blockMap.values()) });
},
// 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 };
}
},
// ========== Server-Side Movement & Actions ==========
// All movement/actions are routed through the server's Turtle.js exec() pipeline
_turtleAction: async (turtleId, action, body = {}) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return await response.json();
} catch (error) {
console.error(` ❌ Error ${action}:`, error);
return { success: false, error: error.message };
}
},
moveForward: async (turtleId) => get()._turtleAction(turtleId, 'forward'),
moveBack: async (turtleId) => get()._turtleAction(turtleId, 'back'),
moveUp: async (turtleId) => get()._turtleAction(turtleId, 'up'),
moveDown: async (turtleId) => get()._turtleAction(turtleId, 'down'),
turnLeft: async (turtleId) => get()._turtleAction(turtleId, 'turnLeft'),
turnRight: async (turtleId) => get()._turtleAction(turtleId, 'turnRight'),
digBlock: async (turtleId) => get()._turtleAction(turtleId, 'dig'),
digBlockUp: async (turtleId) => get()._turtleAction(turtleId, 'digUp'),
digBlockDown: async (turtleId) => get()._turtleAction(turtleId, 'digDown'),
placeBlock: async (turtleId, text) => get()._turtleAction(turtleId, 'place', { text }),
placeBlockUp: async (turtleId, text) => get()._turtleAction(turtleId, 'placeUp', { text }),
placeBlockDown: async (turtleId, text) => get()._turtleAction(turtleId, 'placeDown', { text }),
refuelTurtle: async (turtleId, count) => get()._turtleAction(turtleId, 'refuel-action', { count }),
// Fetch chunk analyses from server
fetchChunkAnalyses: async () => {
try {
const response = await fetch(`${API_URL}/chunks`);
if (response.ok) {
const data = await response.json();
const analyses = {};
data.forEach(c => { analyses[`${c.x},${c.z}`] = c; });
set({ chunkAnalyses: analyses });
}
} catch (error) {
console.error(' ❌ Error fetching chunks:', error);
}
},
// Request chunk analysis for a specific chunk
analyzeChunk: async (chunkX, chunkZ) => {
try {
const response = await fetch(`${API_URL}/chunks/${chunkX}/${chunkZ}/analyze`, {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
set(state => ({
chunkAnalyses: {
...state.chunkAnalyses,
[`${chunkX},${chunkZ}`]: data
}
}));
return data;
}
} catch (error) {
console.error(' ❌ Error analyzing chunk:', error);
}
},
// Search blocks by name pattern
searchBlocks: async (namePattern) => {
try {
const response = await fetch(`${API_URL}/world/blocks/search?name=${encodeURIComponent(namePattern)}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error(' ❌ Error searching blocks:', error);
}
return [];
},
// Rename a turtle
renameTurtle: async (turtleId, name) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return await response.json();
} catch (error) {
console.error(' ❌ Error renaming turtle:', error);
return { success: false, error: error.message };
}
},
// Equip left
equipLeft: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-left`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Equip right
equipRight: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-right`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Select inventory slot
selectSlot: async (turtleId, slot) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Transfer items between slots
transferItems: async (turtleId, fromSlot, toSlot, count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fromSlot, toSlot, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Sort/compact inventory
sortInventory: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/sort`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Connect to adjacent inventory
connectToInventory: async (turtleId, side) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/connect-inventory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Drop items
dropItems: async (turtleId, direction = 'front', count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/drop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Suck items
suckItems: async (turtleId, direction = 'front', count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/suck`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Update turtle config
updateTurtleConfig: async (turtleId, config) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Get turtle config
getTurtleConfig: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`);
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Explore (batched 3-direction inspect)
exploreTurtle: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/explore`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// GPS locate
gpsLocateTurtle: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/gps`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Helper getters
getTurtleArray: () => {
return Object.values(get().turtles);
},
getSelectedTurtle: () => {
const { turtles, selectedTurtleId } = get();
return selectedTurtleId ? turtles[selectedTurtleId] : null;
}
}));