1803 lines
52 KiB
JavaScript
1803 lines
52 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';
|
|
|
|
const app = express();
|
|
const PORT = 3001;
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '5mb' }));
|
|
|
|
// 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 savedBlocks = db.getWorldBlocks();
|
|
console.log(` Loaded ${savedHomes.length} turtle homes`);
|
|
console.log(` Loaded ${savedBlocks.length} world blocks`);
|
|
|
|
// Store connected web clients and turtle data
|
|
const webClients = new Set();
|
|
const turtles = new Map(); // turtleID -> Turtle instance
|
|
const worldBlocks = new Map(); // "x,y,z" -> {name, metadata, discoveredBy, timestamp}
|
|
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
|
|
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
|
|
|
|
// Load saved homes into memory
|
|
for (const home of savedHomes) {
|
|
turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z });
|
|
}
|
|
|
|
// Load saved blocks into memory
|
|
for (const block of savedBlocks) {
|
|
const key = `${block.x},${block.y},${block.z}`;
|
|
worldBlocks.set(key, {
|
|
name: block.block_name,
|
|
metadata: block.metadata,
|
|
discoveredBy: block.discovered_by,
|
|
timestamp: block.discovered_at
|
|
});
|
|
}
|
|
|
|
// Timeout for considering turtles offline (30 seconds)
|
|
const TURTLE_TIMEOUT = 30000;
|
|
|
|
// 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() });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
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);
|
|
broadcastToClients({
|
|
type: 'player_update',
|
|
player: { id: data.playerID, ...data.position },
|
|
});
|
|
}
|
|
}
|
|
} 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
|
|
}));
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// ========== MINING AREA ENDPOINTS ==========
|
|
|
|
// Helper to format mining area for API response
|
|
function formatMiningArea(area) {
|
|
return {
|
|
areaID: area.id,
|
|
turtleID: area.turtle_id,
|
|
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 } = 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');
|
|
|
|
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 status
|
|
app.put('/api/mining-areas/:areaId', (req, res) => {
|
|
try {
|
|
const areaId = parseInt(req.params.areaId);
|
|
const { status } = req.body;
|
|
db.updateMiningAreaStatus(areaId, status);
|
|
|
|
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 } = req.body;
|
|
|
|
if (!playerID || !position) {
|
|
return res.status(400).json({ error: 'Missing playerID or position' });
|
|
}
|
|
|
|
db.savePlayerPosition(playerID, position);
|
|
|
|
// Broadcast to WebSocket clients
|
|
broadcastToClients({
|
|
type: 'player_update',
|
|
playerID,
|
|
position,
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', () => {
|
|
console.log('\n🛑 Shutting down server...');
|
|
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`);
|
|
});
|