import { WebSocketServer } from 'ws'; import { createRequire } from 'module'; import * as db from './database.js'; import { Turtle } from './Turtle.js'; import { TaskDispatcher } from './TaskDispatcher.js'; import { WorldBlockCache } from './WorldBlockCache.js'; const require = createRequire(import.meta.url); const { createPlatformServer, setupGracefulShutdown, createProxyEndpoint, } = require('@cc-platform/server'); // ========== Platform Server Setup ========== const { app, server, auth, start, port } = createPlatformServer({ serviceName: 'turtle-control', port: 3001, cors: true, }); const { requireAuth } = auth; const API_KEY = process.env.API_KEY || ''; // Rewrite requests that arrive without /api prefix (from reverse proxy stripping it) app.use((req, res, next) => { if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') { req.url = '/api' + req.url; } next(); }); // Initialize database db.initializeDatabase(); // Load persisted data from database console.log('๐Ÿ“‚ Loading persisted data from database...'); const savedHomes = db.getAllTurtleHomes(); const blockCount = db.getWorldBlockCount(); console.log(` Loaded ${savedHomes.length} turtle homes`); console.log(` ${blockCount} world blocks in database (demand-loaded)`); // Store connected web clients and turtle data const webClients = new Set(); const turtles = new Map(); // turtleID -> Turtle instance const worldBlocks = new WorldBlockCache(db, parseInt(process.env.BLOCK_CACHE_SIZE || '50000', 10)); const turtleHomes = new Map(); // turtleID -> {x, y, z} home position const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc} // Load saved homes into memory for (const home of savedHomes) { turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z }); } // Timeout for considering turtles offline (30 seconds) const TURTLE_TIMEOUT = 30000; // Task dispatcher โ€” automatically assigns pending tasks to idle turtles const taskDispatcher = new TaskDispatcher({ turtles, db, broadcastToClients: (data) => broadcastToClients(data), pollInterval: parseInt(process.env.DISPATCH_INTERVAL || '5000', 10), }); // Broadcast to all web clients function broadcastToClients(data) { const message = JSON.stringify(data); webClients.forEach((client) => { if (client.readyState === 1) { // OPEN client.send(message); } }); } // ========== 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; } // Auto-name if no label turtle.autoName(); // Try to recover previous state from DB (e.g., if turtle rebooted) turtle.recoverState(); // ---- Event handlers ---- // Forward eval commands to webbridge via WebSocket (or fallback to poll queue) turtle.on('sendCommand', (command) => { pushCommandToBridge(turtleID, command); }); // 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 turtles.entries()) { if (now - turtle.lastUpdate > TURTLE_TIMEOUT) { console.log(`๐Ÿ”Œ Turtle ${turtleID} timed out (last seen ${Math.floor((now - turtle.lastUpdate) / 1000)}s ago)`); turtle.disconnect(); turtles.delete(turtleID); removedCount++; } } if (removedCount > 0) { console.log(`๐Ÿงน Cleaned up ${removedCount} offline turtle(s)`); } }, 10000); // Check every 10 seconds // Helper to store discovered blocks function storeBlock(x, y, z, blockData, turtleID) { const key = `${x},${y},${z}`; worldBlocks.set(key, { ...blockData, discoveredBy: turtleID, timestamp: Date.now() }); // Persist to database db.saveWorldBlock(x, y, z, blockData.name, blockData.metadata, turtleID); } // Helper to calculate block position based on turtle position and facing function getBlockPosition(turtlePos, facing, direction) { if (!turtlePos) return null; const pos = { ...turtlePos }; if (direction === 'up') { pos.y += 1; } else if (direction === 'down') { pos.y -= 1; } else if (direction === 'forward') { if (facing === 0) pos.z -= 1; // North else if (facing === 1) pos.x += 1; // East else if (facing === 2) pos.z += 1; // South else if (facing === 3) pos.x -= 1; // West } return pos; } // WebSocket server for web clients AND bridge connections const wss = new WebSocketServer({ server }); // Track bridge connections separately const bridgeClients = new Set(); /** * Push a command to the webbridge for a specific turtle. * If a bridge is connected via WebSocket, send instantly. * Otherwise, queue for HTTP polling (fallback). */ function pushCommandToBridge(turtleID, command) { let sent = false; for (const bridge of bridgeClients) { if (bridge.readyState === 1) { // OPEN bridge.send(JSON.stringify({ type: 'command', turtleID, command, })); sent = true; } } if (!sent) { // Fallback: queue for HTTP polling (backward compatible) const turtle = turtles.get(turtleID); if (turtle) { turtle.pendingCommands.push({ ...command, timestamp: Date.now() }); console.log(`[Bridge] Queued command for T#${turtleID} via HTTP poll (no WS bridge, ${bridgeClients.size} bridges)`); } } } // WebSocket connection handler wss.on('connection', (ws, req) => { const url = req.url || ''; // ---- Bridge WebSocket connection ---- if (url.startsWith('/ws/bridge')) { console.log('๐ŸŒ‰ Webbridge connected via WebSocket'); bridgeClients.add(ws); // Send list of known turtle IDs so bridge knows who to listen for ws.send(JSON.stringify({ type: 'init', turtleIDs: Array.from(turtles.keys()), })); ws.on('message', (raw) => { try { const data = JSON.parse(raw); // Handle keepalive pings from bridge if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return; } if (data.type === 'status') { // Turtle status update forwarded from bridge const turtleID = data.turtleID; if (!turtleID) return; const turtle = getOrCreateTurtle(turtleID); turtle.updateFromStatus(data); // Store home position if provided if (data.homePosition) { turtleHomes.set(turtleID, data.homePosition); db.saveTurtleHome(turtleID, data.homePosition); } } else if (data.type === 'eval_response') { // Eval response from turtle via bridge const turtle = turtles.get(data.turtleID); if (turtle) { const handled = turtle.handleResponse(data.uuid, data.result, data.error); if (handled) { console.log(`๐Ÿ“ฉ Eval response T#${data.turtleID} uuid:${(data.uuid || '').substring(0, 8)} - resolved`); } } } else if (data.type === 'event') { // Real-time events (inventory, peripheral) const turtle = turtles.get(data.turtleID); if (turtle) { turtle.handleEvent(data.eventType, data.message); } } else if (data.type === 'request_home') { // Turtle requesting home position const home = turtleHomes.get(data.turtleID); ws.send(JSON.stringify({ type: 'home_position', turtleID: data.turtleID, homePosition: home || null, })); } else if (data.type === 'set_home') { // Turtle setting home position const pos = data.position; if (pos && data.turtleID) { turtleHomes.set(data.turtleID, pos); db.saveTurtleHome(data.turtleID, pos); const turtle = turtles.get(data.turtleID); if (turtle) turtle.homePosition = pos; ws.send(JSON.stringify({ type: 'home_set_confirm', turtleID: data.turtleID, homePosition: pos, })); } } else if (data.type === 'blocks_discovered') { // Batch block discovery from turtle if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { storeBlock(block.x, block.y, block.z, { name: block.name, metadata: block.metadata || 0 }, data.turtleID || block.discoveredBy); } broadcastToClients({ type: 'blocks_discovered', blocks: data.blocks.map(b => ({ x: b.x, y: b.y, z: b.z, name: b.name, metadata: b.metadata || 0, discoveredBy: data.turtleID || b.discoveredBy, timestamp: Date.now(), })), }); } } else if (data.type === 'player_update') { // Player position from pocket computer if (data.playerID && data.position) { db.savePlayerPosition(data.playerID, data.position, data.label || null); broadcastToClients({ type: 'player_update', playerID: data.playerID, position: data.position, label: data.label || null, timestamp: Date.now(), }); } } } catch (error) { console.error('โŒ Bridge WS message error:', error.message); } }); ws.on('close', () => { console.log('๐ŸŒ‰ Webbridge disconnected'); bridgeClients.delete(ws); }); ws.on('error', (error) => { console.error('โŒ Bridge WS error:', error); bridgeClients.delete(ws); }); return; } // ---- Web client WebSocket connection ---- console.log('๐ŸŒ New web client connected'); webClients.add(ws); // Send current turtle data and world blocks to new client const blocks = worldBlocks.getAllBlocksForAPI(); ws.send(JSON.stringify({ type: 'initial_state', turtles: Array.from(turtles.values()).map(t => t.toJSON()), blocks: blocks, players: db.getAllPlayerPositions() })); ws.on('message', (message) => { try { const data = JSON.parse(message); console.log('๐Ÿ“จ Received from web client:', data); // Handle commands from web client if (data.type === 'command') { const turtleID = data.turtleID; const turtle = turtles.get(turtleID); if (turtle) { // All commands are state changes โ€” server controls all turtle movement 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 { console.log(`โš ๏ธ Unrecognized command from web client: ${data.command} โ€” use state machine or server-side action routes`); } } else { console.log(`โŒ Turtle ${turtleID} not found`); console.log(` Available turtles:`, Array.from(turtles.keys())); } } } catch (error) { console.error('โŒ Error processing web client message:', error); } }); ws.on('close', () => { console.log('๐Ÿ‘‹ Web client disconnected'); webClients.delete(ws); }); ws.on('error', (error) => { console.error('โŒ WebSocket error:', error); }); }); // REST API endpoint for turtles to update their status app.post('/api/turtle/update', (req, res) => { try { const turtleUpdate = req.body; const turtleID = turtleUpdate.turtleID; console.log(`๐Ÿข Status update from Turtle ${turtleID}`); // Get or create Turtle instance const turtle = getOrCreateTurtle(turtleID); // 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); } // Store discovered blocks in world map if (turtleUpdate.surroundings && turtleUpdate.position && turtleUpdate.facing !== undefined) { for (const [direction, blockData] of Object.entries(turtleUpdate.surroundings)) { const blockPos = getBlockPosition(turtleUpdate.position, turtleUpdate.facing, direction); if (blockPos) { storeBlock(blockPos.x, blockPos.y, blockPos.z, blockData, turtleID); } } } // Broadcast to web clients broadcastToClients({ type: 'turtle_update', turtle: turtle.toJSON() }); // Send back server-authoritative data res.json({ success: true, homePosition: turtleHomes.get(turtleID), config: turtleConfig.get(turtleID) || {} }); } catch (error) { console.error('โŒ Error processing turtle update:', error); res.status(500).json({ error: error.message }); } }); // 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 (turtle) { // Get pending eval commands for webbridge const commands = turtle.pendingCommands || []; // Clean up old commands (older than 30 seconds) const now = Date.now(); turtle.pendingCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000); if (turtle.pendingCommands.length > 0) { console.log(`๐Ÿ“ค Sending ${turtle.pendingCommands.length} eval command(s) to turtle ${turtleID}`); turtle.pendingCommands.forEach(cmd => { if (cmd.type === 'eval') { console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`); } }); } res.json({ commands: turtle.pendingCommands }); } else { res.json({ commands: [] }); } } catch (error) { console.error('โŒ Error getting commands:', error); res.status(500).json({ error: error.message }); } }); // Acknowledge commands were delivered (clears them) app.post('/api/turtle/:id/commands/ack', (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (turtle) { const clearedCount = (turtle.pendingCommands || []).length; if (clearedCount > 0) { console.log(`โœ… Turtle ${turtleID} acknowledged ${clearedCount} command(s)`); turtle.pendingCommands = []; } res.json({ success: true, cleared: clearedCount }); } else { res.status(404).json({ error: 'Turtle not found' }); } } catch (error) { console.error('โŒ Error acknowledging commands:', error); res.status(500).json({ error: error.message }); } }); // Receive real-time events from turtles (inventory, peripherals) app.post('/api/turtle/event', (req, res) => { try { const { turtleID, type, message } = req.body; if (!turtleID || !type) { return res.status(400).json({ error: 'Missing turtleID or type' }); } const turtle = turtles.get(turtleID); if (turtle) { turtle.handleEvent(type, message); console.log(`๐Ÿ“ก Event from T#${turtleID}: ${type}`); } res.json({ success: true }); } catch (error) { console.error('โŒ Error processing turtle event:', error); res.status(500).json({ error: error.message }); } }); // Get all turtles app.get('/api/turtles', (req, res) => { res.json({ turtles: Array.from(turtles.values()).map(t => t.toJSON()) }); }); // Set home position for a turtle app.post('/api/turtle/:id/home', (req, res) => { try { const turtleID = parseInt(req.params.id); const { position } = req.body; if (!position || position.x === undefined || position.y === undefined || position.z === undefined) { return res.status(400).json({ error: 'Invalid position' }); } turtleHomes.set(turtleID, position); console.log(`๐Ÿ“ Set home for turtle ${turtleID}:`, position); // Persist to database db.saveTurtleHome(turtleID, position); // Update turtle instance const turtle = turtles.get(turtleID); if (turtle) { turtle.homePosition = position; // Broadcast update broadcastToClients({ type: 'turtle_update', turtle: turtle.toJSON() }); } res.json({ success: true, homePosition: position }); } catch (error) { console.error('โŒ Error setting home:', error); res.status(500).json({ error: error.message }); } }); // Get turtle home position app.get('/api/turtle/:id/home', (req, res) => { try { const turtleID = parseInt(req.params.id); const home = turtleHomes.get(turtleID); res.json({ success: true, homePosition: home || null }); } catch (error) { console.error('โŒ Error getting home:', error); res.status(500).json({ error: error.message }); } }); // Get world blocks for map visualization app.get('/api/world/blocks', (req, res) => { const blocks = worldBlocks.getAllBlocksForAPI(); res.json({ blocks }); }); // Store a discovered block app.post('/api/world/blocks', (req, res) => { try { const { x, y, z, blockName, metadata, discoveredBy } = req.body; if (x === undefined || y === undefined || z === undefined || !blockName) { return res.status(400).json({ error: 'Missing required fields: x, y, z, blockName' }); } const blockData = { name: blockName, metadata: metadata || 0 }; storeBlock(x, y, z, blockData, discoveredBy); // Broadcast to web clients broadcastToClients({ type: 'block_discovered', block: { x, y, z, name: blockName, metadata: metadata || 0, discoveredBy, timestamp: Date.now() } }); res.json({ success: true }); } catch (error) { console.error('โŒ Error storing block:', error); res.status(500).json({ error: error.message }); } }); // Send command to turtle 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 (turtle) { // All commands are state changes โ€” server controls all turtle movement 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}`); res.json({ success: true }); } else { console.log(`โš ๏ธ Legacy command rejected for turtle ${turtleID}: ${command} โ€” use state machine or server-side action routes`); res.status(400).json({ error: 'Legacy commands are no longer supported. Use state machine or action routes.' }); } } else { res.status(404).json({ error: 'Turtle not found' }); } } catch (error) { console.error('โŒ Error sending command:', error); res.status(500).json({ error: error.message }); } }); // ========== SERVER-SIDE MOVEMENT & ACTION ENDPOINTS ========== // Generic helper for turtle action routes function turtleAction(routePath, actionFn) { app.post(`/api/turtle/:id/${routePath}`, async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const result = await actionFn(turtle, req.body); res.json({ success: true, result }); } catch (error) { console.error(`โŒ Error in /${routePath} for turtle ${req.params.id}:`, error.message); res.json({ success: false, error: error.message }); } }); } // Movement turtleAction('forward', (t) => t.forward()); turtleAction('back', (t) => t.back()); turtleAction('up', (t) => t.up()); turtleAction('down', (t) => t.down()); turtleAction('turnLeft', (t) => t.turnLeft()); turtleAction('turnRight', (t) => t.turnRight()); // Digging turtleAction('dig', (t) => t.dig()); turtleAction('digUp', (t) => t.digUp()); turtleAction('digDown', (t) => t.digDown()); // Placing turtleAction('place', (t, body) => t.place(body?.text)); turtleAction('placeUp', (t, body) => t.placeUp(body?.text)); turtleAction('placeDown', (t, body) => t.placeDown(body?.text)); // Refuel (via server) turtleAction('refuel-action', (t, body) => t.refuel(body?.count)); // ========== 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 app.post('/api/paths', (req, res) => { try { const { turtleId, pathName, pathData } = req.body; const pathId = db.savePath(turtleId, pathName, pathData || []); res.json({ success: true, pathId, message: 'Path saved' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all paths (optionally filtered by turtleId) app.get('/api/paths', (req, res) => { try { const paths = db.getPaths(); res.json(paths.map(p => ({ pathId: p.id, turtleId: p.turtle_id, name: p.path_name, pathData: p.path_data, waypointCount: Array.isArray(p.path_data) ? p.path_data.length : 0, createdAt: p.created_at, updatedAt: p.updated_at }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get paths for a specific turtle app.get('/api/paths/turtle/:turtleId', (req, res) => { try { const turtleId = parseInt(req.params.turtleId); const paths = db.getPaths(turtleId); res.json(paths.map(p => ({ pathId: p.id, turtleId: p.turtle_id, name: p.path_name, pathData: p.path_data, waypointCount: Array.isArray(p.path_data) ? p.path_data.length : 0, createdAt: p.created_at, updatedAt: p.updated_at }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get a specific path with full details app.get('/api/paths/:pathId', (req, res) => { try { const pathId = parseInt(req.params.pathId); const path = db.getPath(pathId); if (!path) { return res.status(404).json({ error: 'Path not found' }); } res.json({ pathId: path.id, turtleId: path.turtle_id, name: path.path_name, waypoints: path.path_data, createdAt: path.created_at, updatedAt: path.updated_at }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete a path app.delete('/api/paths/:pathId', (req, res) => { try { const pathId = parseInt(req.params.pathId); db.deletePath(pathId); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== TASK QUEUE ENDPOINTS ========== // Create a new task app.post('/api/tasks', (req, res) => { try { const { taskType, priority, assignedTurtleId, parameters } = req.body; const taskData = parameters || {}; const taskId = db.createTask(taskType, taskData, priority || 0, assignedTurtleId || null); broadcastToClients({ type: 'task_created', taskId, taskType, priority }); res.json({ success: true, taskId }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all tasks (with optional status filter) app.get('/api/tasks', (req, res) => { try { const status = req.query.status || null; const rawTasks = db.getAllTasks(status); const tasks = rawTasks.map(t => ({ taskId: t.id, taskType: t.task_type, priority: t.priority, status: t.status, assignedTurtleId: t.assigned_turtle_id, parameters: t.task_data, result: t.task_data?.result || null, createdAt: t.created_at, updatedAt: t.updated_at })); res.json(tasks); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get next available task app.get('/api/tasks/next', (req, res) => { try { const task = db.getNextTask(); res.json({ task }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update task status app.put('/api/tasks/:taskId', (req, res) => { try { const taskId = parseInt(req.params.taskId); const { status, result } = req.body; db.updateTaskStatus(taskId, status, result); broadcastToClients({ type: 'task_updated', taskId, status }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete a task app.delete('/api/tasks/:taskId', (req, res) => { try { const taskId = parseInt(req.params.taskId); db.deleteTask(taskId); broadcastToClients({ type: 'task_deleted', taskId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Assign task to turtle app.post('/api/tasks/:taskId/assign', (req, res) => { try { const taskId = parseInt(req.params.taskId); const { turtleId } = req.body; db.assignTask(taskId, turtleId); broadcastToClients({ type: 'task_assigned', taskId, turtleId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Complete a task app.post('/api/tasks/:taskId/complete', (req, res) => { try { const taskId = parseInt(req.params.taskId); db.completeTask(taskId); broadcastToClients({ type: 'task_completed', taskId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Cancel a running task (stops turtle + marks cancelled) app.post('/api/tasks/:taskId/cancel', (req, res) => { try { const taskId = parseInt(req.params.taskId); taskDispatcher.cancelTask(taskId); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== TASK DISPATCHER ENDPOINTS ========== // Get dispatcher status app.get('/api/dispatcher/status', (req, res) => { res.json(taskDispatcher.status()); }); // Enable/disable auto-dispatch app.post('/api/dispatcher/toggle', (req, res) => { const { enabled } = req.body; taskDispatcher.enabled = enabled !== undefined ? enabled : !taskDispatcher.enabled; res.json({ enabled: taskDispatcher.enabled }); }); // ========== MINING AREA ENDPOINTS ========== // Helper to format mining area for API response function formatMiningArea(area) { return { areaID: area.id, turtleID: area.turtle_id, areaName: area.name || `Area #${area.id}`, color: area.color || '#4a8c2a', startX: area.min_x, startY: area.min_y, startZ: area.min_z, endX: area.max_x, endY: area.max_y, endZ: area.max_z, status: area.status, createdAt: area.created_at, updatedAt: area.updated_at }; } // Save a mining area app.post('/api/mining-areas', (req, res) => { try { const { turtleId, turtleID, bounds, startX, startY, startZ, endX, endY, endZ, areaName, status, color } = req.body; const tid = turtleId || turtleID; // Support both {turtleId, bounds} and flat {startX,startY,...} formats const areaBounds = bounds || { minX: Math.min(Number(startX), Number(endX)), minY: Math.min(Number(startY), Number(endY)), minZ: Math.min(Number(startZ), Number(endZ)), maxX: Math.max(Number(startX), Number(endX)), maxY: Math.max(Number(startY), Number(endY)), maxZ: Math.max(Number(startZ), Number(endZ)) }; db.saveMiningArea(tid, areaBounds, areaName || null, status || 'planned', color || '#4a8c2a'); const areas = db.getMiningAreas(); broadcastToClients({ type: 'mining_areas_updated', areas: areas.map(formatMiningArea) }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all mining areas app.get('/api/mining-areas', (req, res) => { try { const areas = db.getMiningAreas(); res.json(areas.map(formatMiningArea)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update mining area app.put('/api/mining-areas/:areaId', (req, res) => { try { const areaId = parseInt(req.params.areaId); const { status, name, color } = req.body; // Build updates object with only provided fields const updates = {}; if (status !== undefined) updates.status = status; if (name !== undefined) updates.name = name; if (color !== undefined) updates.color = color; if (Object.keys(updates).length > 0) { db.updateMiningArea(areaId, updates); } const areas = db.getMiningAreas(); broadcastToClients({ type: 'mining_areas_updated', areas: areas.map(formatMiningArea) }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete a mining area app.delete('/api/mining-areas/:areaId', (req, res) => { try { const areaId = parseInt(req.params.areaId); db.deleteMiningArea(areaId); const areas = db.getMiningAreas(); broadcastToClients({ type: 'mining_areas_updated', areas: areas.map(formatMiningArea) }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Close a mining area (legacy endpoint) app.post('/api/mining-areas/:areaId/close', (req, res) => { try { const areaId = parseInt(req.params.areaId); db.closeMiningArea(areaId); const areas = db.getMiningAreas(); broadcastToClients({ type: 'mining_areas_updated', areas: areas.map(formatMiningArea) }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== CHUNK ANALYSIS ENDPOINTS ========== // Get all chunk analyses app.get('/api/chunks', (req, res) => { try { const chunks = db.getAllChunkAnalyses(); res.json(chunks); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get specific chunk analysis app.get('/api/chunks/:x/:z', (req, res) => { try { const x = parseInt(req.params.x); const z = parseInt(req.params.z); const chunk = db.getChunkAnalysis(x, z); res.json(chunk || { x, z, analysis: {} }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Save chunk analysis app.post('/api/chunks', (req, res) => { try { const { x, z, analysis } = req.body; db.saveChunkAnalysis(x, z, analysis || {}); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Analyze a chunk (compute ore density from discovered blocks) app.post('/api/chunks/:x/:z/analyze', (req, res) => { try { const chunkX = parseInt(req.params.x); const chunkZ = parseInt(req.params.z); // Chunk bounds in world coordinates (16x16 columns, full Y range) const minX = chunkX * 16; const maxX = minX + 15; const minZ = chunkZ * 16; const maxZ = minZ + 15; // Get all blocks in the chunk from the DB const blocks = db.getWorldBlocksInArea(minX, -64, minZ, maxX, 320, maxZ); // Count ores const oreCounts = {}; let totalBlocks = 0; if (blocks && Array.isArray(blocks)) { for (const block of blocks) { totalBlocks++; if (block.block_name && block.block_name.includes('ore')) { oreCounts[block.block_name] = (oreCounts[block.block_name] || 0) + 1; } } } const analysis = { x: chunkX, z: chunkZ, totalBlocks, ores: oreCounts, scannedAt: Date.now(), }; // Save the analysis db.saveChunkAnalysis(chunkX, chunkZ, analysis); res.json(analysis); } catch (error) { res.status(500).json({ error: error.message }); } }); // Search blocks by name pattern in area app.get('/api/world/blocks/search', (req, res) => { try { const { fromX, fromY, fromZ, toX, toY, toZ, pattern, name } = req.query; const searchPattern = pattern || name; if (!searchPattern) { return res.status(400).json({ error: 'Missing pattern or name parameter' }); } const blocks = db.getBlocksWithNameLike( parseInt(fromX) || -1000, parseInt(fromY) || -64, parseInt(fromZ) || -1000, parseInt(toX) || 1000, parseInt(toY) || 320, parseInt(toZ) || 1000, searchPattern ); res.json({ blocks }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get single block info app.get('/api/world/block/:x/:y/:z', (req, res) => { try { const x = parseInt(req.params.x); const y = parseInt(req.params.y); const z = parseInt(req.params.z); const block = db.getBlock(x, y, z); res.json(block || null); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== STATISTICS ENDPOINTS ========== // Get server statistics app.get('/api/stats', (req, res) => { try { const stats = { activeTurtles: turtles.size, turtles: turtles.size, totalBlocks: worldBlocks.size, blocks: worldBlocks.size, savedHomes: turtleHomes.size, connectedClients: webClients.size, tasks: db.getAllTasks().length, miningAreas: db.getMiningAreas().length, groups: db.getAllGroups().length }; res.json(stats); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get mining statistics app.get('/api/stats/mining/:turtleId?', (req, res) => { try { const turtleId = req.params.turtleId ? parseInt(req.params.turtleId) : null; const days = parseInt(req.query.days) || 7; const stats = db.getMiningStats(turtleId, days); if (turtleId) { // Single turtle: return { turtleId, blocks: [{blockType, count}] } res.json({ turtleId, blocks: stats.map(s => ({ blockType: s.block_type, count: s.total_count })) }); } else { // All turtles: group by turtle_id, return array of { turtleId, blocks: [...] } const grouped = {}; stats.forEach(s => { if (!grouped[s.turtle_id]) { grouped[s.turtle_id] = { turtleId: s.turtle_id, blocks: [] }; } grouped[s.turtle_id].blocks.push({ blockType: s.block_type, count: s.total_count }); }); res.json(Object.values(grouped)); } } catch (error) { res.status(500).json({ error: error.message }); } }); // Get top miners leaderboard app.get('/api/stats/top-miners', (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const topMiners = db.getTopMiners(limit); res.json(topMiners.map(m => ({ turtleId: m.turtle_id, totalBlocks: m.total_blocks, uniqueTypes: m.unique_types }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // Record block mined (called by turtles) app.post('/api/stats/block-mined', (req, res) => { try { const { turtleId, blockType } = req.body; db.recordBlockMined(turtleId, blockType); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== TURTLE GROUPS/TEAMS ENDPOINTS ========== // Helper to format group for API response function formatGroup(group, members) { return { groupId: group.id, groupName: group.group_name, color: group.color, memberCount: members.length, members: members.map(m => ({ turtleId: m.turtle_id, joinedAt: m.joined_at })), createdAt: group.created_at }; } // Create a new group app.post('/api/groups', (req, res) => { try { const { groupName, color } = req.body; const groupId = db.createGroup(groupName, color); broadcastToClients({ type: 'group_created', groupId, groupName, color }); res.json({ success: true, groupId }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all groups app.get('/api/groups', (req, res) => { try { const groups = db.getAllGroups(); const result = groups.map(group => { const members = db.getGroupMembers(group.id); return formatGroup(group, members); }); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete a group app.delete('/api/groups/:groupId', (req, res) => { try { const groupId = parseInt(req.params.groupId); db.deleteGroup(groupId); broadcastToClients({ type: 'group_deleted', groupId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Add turtle to group app.post('/api/groups/:groupId/members', (req, res) => { try { const groupId = parseInt(req.params.groupId); const { turtleId } = req.body; db.addTurtleToGroup(turtleId, groupId); broadcastToClients({ type: 'turtle_added_to_group', groupId, turtleId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Remove turtle from group app.delete('/api/groups/:groupId/members/:turtleId', (req, res) => { try { const groupId = parseInt(req.params.groupId); const turtleId = parseInt(req.params.turtleId); db.removeTurtleFromGroup(turtleId, groupId); broadcastToClients({ type: 'turtle_removed_from_group', groupId, turtleId }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get turtle's groups app.get('/api/turtles/:turtleId/groups', (req, res) => { try { const turtleId = parseInt(req.params.turtleId); const groups = db.getTurtleGroups(turtleId); res.json({ groups }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Send command to all turtles in a group app.post('/api/groups/:groupId/command', (req, res) => { try { const groupId = parseInt(req.params.groupId); const { command, param } = req.body; const members = db.getGroupMembers(groupId); let successCount = 0; for (const member of members) { const turtle = turtles.get(member.turtle_id); if (turtle) { // All group commands are state changes โ€” server controls all movement if (command === 'set_state' || command === 'setState') { const stateName = param?.state || param; const stateData = param?.data || {}; turtle.setState(stateName, stateData); successCount++; } else { console.log(`โš ๏ธ Legacy group command rejected: ${command}`); } } } res.json({ success: true, sentTo: successCount }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Player position endpoints app.post('/api/player/update', (req, res) => { try { const { playerID, position, timestamp, label } = req.body; if (!playerID || !position) { return res.status(400).json({ error: 'Missing playerID or position' }); } db.savePlayerPosition(playerID, position, label || null); // Broadcast to WebSocket clients broadcastToClients({ type: 'player_update', playerID, position, label: label || null, timestamp: timestamp || Date.now() }); res.json({ success: true, playerID, position }); } catch (error) { console.error('Error updating player position:', error); res.status(500).json({ error: error.message }); } }); app.get('/api/players', (req, res) => { try { const players = db.getAllPlayerPositions(); res.json(players); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/player/:id', (req, res) => { try { const playerId = parseInt(req.params.id); const player = db.getPlayerPosition(playerId); if (!player) { return res.status(404).json({ error: 'Player not found' }); } res.json(player); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== TURTLE ACTION ENDPOINTS ========== // Rename a turtle app.post('/api/turtle/:id/rename', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { name } = req.body; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Missing or invalid name' }); } const turtle = turtles.get(turtleID); if (!turtle) { return res.status(404).json({ error: 'Turtle not found' }); } await turtle.rename(name.trim()); console.log(`๐Ÿ“ Turtle ${turtleID} renamed to "${name.trim()}"`); res.json({ success: true, label: turtle.label }); } catch (error) { console.error(`โŒ Error renaming turtle ${req.params.id}:`, error.message); res.json({ success: false, error: error.message }); } }); // Equip item on left side app.post('/api/turtle/:id/equip-left', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const result = await turtle.equipLeft(); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Equip item on right side app.post('/api/turtle/:id/equip-right', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const result = await turtle.equipRight(); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Select inventory slot app.post('/api/turtle/:id/select', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { slot } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const result = await turtle.select(slot); res.json({ success: true, result, selectedSlot: turtle.selectedSlot }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Transfer items between slots app.post('/api/turtle/:id/transfer', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { fromSlot, toSlot, count } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); await turtle.inventoryTransfer(fromSlot, toSlot, count); res.json({ success: true }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Sort inventory (compact items on turtle) app.post('/api/turtle/:id/sort', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); // Send a Lua eval that sorts inventory by compacting items const result = await turtle.exec(` local moved = 0 for slot = 1, 16 do local item = turtle.getItemDetail(slot) if item then for target = 1, slot - 1 do local targetItem = turtle.getItemDetail(target) if not targetItem then turtle.select(slot) turtle.transferTo(target) moved = moved + 1 break elseif targetItem.name == item.name and targetItem.count < targetItem.maxCount then turtle.select(slot) turtle.transferTo(target) moved = moved + 1 if turtle.getItemCount(slot) == 0 then break end end end end end turtle.select(1) return {moved = moved} `); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Connect to adjacent inventory (peripheral) app.post('/api/turtle/:id/connect-inventory', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { side } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); if (!side) return res.status(400).json({ error: 'Missing side parameter' }); const result = await turtle.connectToInventory(side); res.json({ success: true, inventory: result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Drop items (front/up/down) app.post('/api/turtle/:id/drop', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { direction, count } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); let result; if (direction === 'up') result = await turtle.dropUp(count); else if (direction === 'down') result = await turtle.dropDown(count); else result = await turtle.drop(count); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Suck items (front/up/down) app.post('/api/turtle/:id/suck', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { direction, count } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); let result; if (direction === 'up') result = await turtle.suckUp(count); else if (direction === 'down') result = await turtle.suckDown(count); else result = await turtle.suck(count); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Update turtle config app.post('/api/turtle/:id/config', (req, res) => { try { const turtleID = parseInt(req.params.id); const configData = req.body; if (!configData || typeof configData !== 'object') { return res.status(400).json({ error: 'Invalid config data' }); } // Merge with existing config const existing = turtleConfig.get(turtleID) || {}; const merged = { ...existing, ...configData }; turtleConfig.set(turtleID, merged); // Persist to database try { db.saveTurtleConfig(turtleID, merged); } catch (e) { /* ignore if method doesn't exist */ } console.log(`โš™๏ธ Config updated for turtle ${turtleID}:`, merged); res.json({ success: true, config: merged }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get turtle config app.get('/api/turtle/:id/config', (req, res) => { try { const turtleID = parseInt(req.params.id); const config = turtleConfig.get(turtleID) || {}; res.json({ success: true, config }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Explore (batched 3-direction inspect) app.post('/api/turtle/:id/explore', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const result = await turtle.explore(); res.json({ success: true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // GPS locate app.post('/api/turtle/:id/gps', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const position = await turtle.gpsLocate(); res.json({ success: true, position }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Write a file to turtle filesystem app.post('/api/turtle/:id/write-file', async (req, res) => { try { const turtleID = parseInt(req.params.id); const { path, content, append } = req.body; const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); if (!path || content === undefined) return res.status(400).json({ error: 'Missing path or content' }); const result = await turtle.writeToFile(path, content, append || false); res.json({ success: result === true, result }); } catch (error) { res.json({ success: false, error: error.message }); } }); // Refresh detailed inventory state app.post('/api/turtle/:id/refresh-inventory', async (req, res) => { try { const turtleID = parseInt(req.params.id); const turtle = turtles.get(turtleID); if (!turtle) return res.status(404).json({ error: 'Turtle not found' }); const inventory = await turtle.refreshInventoryState(); res.json({ success: true, inventory }); } catch (error) { res.json({ success: false, error: error.message }); } }); // ========== Cross-Project Integration API ========== // These endpoints allow the Inventory Manager system to query turtle state // Get all turtle summaries (for inventory dashboard sidebar widget) app.get('/api/integration/turtle-summary', (req, res) => { const summaries = []; for (const [id, turtle] of turtles) { summaries.push({ id, name: turtle.name || `Turtle ${id}`, state: turtle.currentStateName || 'unknown', fuel: turtle.fuelLevel ?? null, position: turtle.position || null, inventoryUsed: turtle.inventory ? turtle.inventory.filter(s => s && s.name).length : 0, connected: turtle.connected || false, }); } res.json({ turtles: summaries }); }); // Proxy to inventory server (locate items for turtle pickup) createProxyEndpoint(app, '/api/integration/inventory-locate', 'INVENTORY_SERVER_URL', '/api/integration/locate-item'); // Proxy to inventory server (low stock alerts โ€” turtles could auto-mine missing resources) createProxyEndpoint(app, '/api/integration/inventory-alerts', 'INVENTORY_SERVER_URL', '/api/integration/low-stock'); // Proxy to inventory server (storage space check โ€” should turtles keep mining?) createProxyEndpoint(app, '/api/integration/storage-status', 'INVENTORY_SERVER_URL', '/api/integration/storage-status'); // ========== Graceful Shutdown & Start ========== setupGracefulShutdown({ serviceName: 'turtle-control', cleanup: [ () => taskDispatcher.stop(), () => db.closeDatabase(), ], }); start(() => { console.log(`\nConfigured turtles to send updates to:`); console.log(` http://localhost:${port}/api/turtle/update`); if (process.env.INVENTORY_SERVER_URL) { console.log(`๐Ÿ“ฆ Inventory server integration: ${process.env.INVENTORY_SERVER_URL}`); } // Start task dispatcher after server is ready taskDispatcher.start(); });