519 lines
17 KiB
JavaScript
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;
|
|
}
|
|
}));
|