Files
remoteturtle/server/server.js

1976 lines
58 KiB
JavaScript

import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { createServer } from 'http';
import * as db from './database.js';
import { Turtle } from './Turtle.js';
import { TaskDispatcher } from './TaskDispatcher.js';
import { WorldBlockCache } from './WorldBlockCache.js';
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json({ limit: '5mb' }));
// ========== API Key Authentication ==========
const API_KEY = process.env.API_KEY || '';
function extractApiKey(req) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) return auth.slice(7);
return req.headers['x-api-key'] || req.query.key || '';
}
function requireAuth(req, res, next) {
if (!API_KEY) return next(); // Auth disabled when no key configured
if (extractApiKey(req) === API_KEY) return next();
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
}
// Protect mutating endpoints when an API key is set
app.use((req, res, next) => {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next();
}
return requireAuth(req, res, next);
});
// 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;
}
// Create HTTP server
const server = createServer(app);
// WebSocket server for web clients AND bridge connections
const wss = new WebSocketServer({ server });
// Track bridge connections separately
const bridgeClients = new Set();
console.log(`🚀 Turtle Control Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
/**
* 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 = [];
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({ x, y, z, ...blockData });
}
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 = [];
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({
x, y, z,
...blockData
});
}
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
const INVENTORY_SERVER_URL = process.env.INVENTORY_SERVER_URL || ''; // e.g. http://inventory-server:3001
// 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)
app.get('/api/integration/inventory-locate', async (req, res) => {
if (!INVENTORY_SERVER_URL) {
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
}
try {
const params = new URLSearchParams(req.query);
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/locate-item?${params}`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
}
});
// Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources)
app.get('/api/integration/inventory-alerts', async (req, res) => {
if (!INVENTORY_SERVER_URL) {
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
}
try {
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/low-stock`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
}
});
// Proxy to inventory server (storage space check — should turtles keep mining?)
app.get('/api/integration/storage-status', async (req, res) => {
if (!INVENTORY_SERVER_URL) {
return res.json({ configured: false, message: 'INVENTORY_SERVER_URL not configured' });
}
try {
const resp = await fetch(`${INVENTORY_SERVER_URL}/api/integration/storage-status`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach inventory server: ${err.message}` });
}
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down server...');
taskDispatcher.stop();
db.closeDatabase();
process.exit(0);
});
server.listen(PORT, () => {
console.log(`✅ Server ready!`);
console.log(`\nConfigured turtles to send updates to:`);
console.log(` http://localhost:${PORT}/api/turtle/update`);
if (INVENTORY_SERVER_URL) {
console.log(`📦 Inventory server integration: ${INVENTORY_SERVER_URL}`);
}
// Start task dispatcher after server is ready
taskDispatcher.start();
});