import express from 'express'; import { WebSocketServer } from 'ws'; import cors from 'cors'; import { createServer } from 'http'; import * as db from './database.js'; const app = express(); const PORT = 3001; const WS_PORT = 3002; app.use(cors()); app.use(express.json()); // 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 turtleData = new Map(); // turtleID -> turtle state 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); } }); } // Cleanup stale turtles periodically setInterval(() => { const now = Date.now(); let removedCount = 0; for (const [turtleID, turtle] of turtleData.entries()) { if (now - turtle.lastUpdate > TURTLE_TIMEOUT) { console.log(`๐Ÿ”Œ Turtle ${turtleID} timed out (last seen ${Math.floor((now - turtle.lastUpdate) / 1000)}s ago)`); turtleData.delete(turtleID); removedCount++; // Notify web clients broadcastToClients({ type: 'turtle_removed', turtleID: turtleID }); } } 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') { // Calculate based on facing direction 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 const wss = new WebSocketServer({ port: WS_PORT }); console.log(`๐Ÿš€ Turtle Control Server starting...`); console.log(`๐Ÿ“ก HTTP Server: http://localhost:${PORT}`); console.log(`๐Ÿ”Œ WebSocket Server: ws://localhost:${WS_PORT}`); // WebSocket connection handler wss.on('connection', (ws) => { 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(turtleData.values()), 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') { // Store command for turtle to poll const turtleID = data.turtleID; if (turtleData.has(turtleID)) { const turtle = turtleData.get(turtleID); turtle.pendingCommands = turtle.pendingCommands || []; turtle.pendingCommands.push({ command: data.command, param: data.param, timestamp: Date.now() }); console.log(`โœ… Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${data.param})` : ''); console.log(` Pending commands: ${turtle.pendingCommands.length}`); } else { console.log(`โŒ Turtle ${turtleID} not found in turtleData`); console.log(` Available turtles:`, Array.from(turtleData.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}`); // Update turtle data if (!turtleData.has(turtleID)) { console.log(`โœจ New turtle registered: ${turtleID}`); } // Merge with existing data, keeping server's home position if it exists const existingData = turtleData.get(turtleID) || {}; const serverHome = turtleHomes.get(turtleID); turtleData.set(turtleID, { ...existingData, ...turtleUpdate, homePosition: serverHome || turtleUpdate.homePosition, // Server's home takes precedence lastUpdate: Date.now() }); // If turtle reports a home but server doesn't have one, store it if (turtleUpdate.homePosition && !serverHome) { turtleHomes.set(turtleID, 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: turtleData.get(turtleID) }); // 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 turtles to poll for commands app.get('/api/turtle/:id/commands', (req, res) => { try { const turtleID = parseInt(req.params.id); if (turtleData.has(turtleID)) { const turtle = turtleData.get(turtleID); const commands = turtle.pendingCommands || []; if (commands.length > 0) { console.log(`๐Ÿ“ค Sending ${commands.length} command(s) to turtle ${turtleID}`); commands.forEach(cmd => console.log(` - ${cmd.command}`, cmd.param ? `(${cmd.param})` : '')); } // Mark commands as sent but don't clear them yet - they'll be cleared on next poll if turtle is processing // This allows the webbridge to retry if the turtle didn't receive them if (!turtle.lastCommandPollTime || (Date.now() - turtle.lastCommandPollTime) > 5000) { // First poll or more than 5 seconds since last poll - send commands turtle.lastCommandPollTime = Date.now(); res.json({ commands }); } else { // Recent poll - assume previous commands were received, clear them if (commands.length > 0) { console.log(`โœ… Clearing ${commands.length} command(s) for turtle ${turtleID} (acknowledged)`); turtle.pendingCommands = []; } res.json({ commands: [] }); } } 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); if (turtleData.has(turtleID)) { const turtle = turtleData.get(turtleID); 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 }); } }); // Get all turtles app.get('/api/turtles', (req, res) => { res.json({ turtles: Array.from(turtleData.values()) }); }); // 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 data if (turtleData.has(turtleID)) { const turtle = turtleData.get(turtleID); turtle.homePosition = position; turtleData.set(turtleID, turtle); // Broadcast update broadcastToClients({ type: 'turtle_update', turtle: turtle }); } 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 }); }); // Send command to turtle app.post('/api/turtle/:id/command', (req, res) => { try { const turtleID = parseInt(req.params.id); const { command, param } = req.body; if (turtleData.has(turtleID)) { const turtle = turtleData.get(turtleID); turtle.pendingCommands = turtle.pendingCommands || []; turtle.pendingCommands.push({ command, param, timestamp: Date.now() }); console.log(`๐Ÿ“ค Command queued for turtle ${turtleID}:`, command); res.json({ success: true }); } else { res.status(404).json({ error: 'Turtle not found' }); } } catch (error) { console.error('โŒ Error sending command:', error); res.status(500).json({ error: error.message }); } }); // Cleanup stale turtles (haven't updated in 30 seconds) setInterval(() => { const now = Date.now(); const timeout = 30000; // 30 seconds for (const [turtleID, turtle] of turtleData.entries()) { if (now - turtle.lastUpdate > timeout) { console.log(`๐Ÿ—‘๏ธ Removing stale turtle: ${turtleID}`); turtleData.delete(turtleID); broadcastToClients({ type: 'turtle_disconnected', turtleID }); } } }, 10000); // Check every 10 seconds // ========== PATH RECORDING ENDPOINTS ========== // Save a recorded path app.post('/api/paths', (req, res) => { try { const { turtleId, pathName, pathData } = req.body; db.savePath(turtleId, pathName, pathData); res.json({ success: true, message: 'Path saved' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all paths for a turtle app.get('/api/paths/:turtleId', (req, res) => { try { const turtleId = parseInt(req.params.turtleId); const paths = db.getPaths(turtleId); res.json({ paths }); } 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, taskData, priority } = req.body; const taskId = db.createTask(taskType, taskData, priority || 0); broadcastToClients({ type: 'task_created', taskId, taskType, priority }); res.json({ success: true, taskId }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all tasks app.get('/api/tasks', (req, res) => { try { const tasks = db.getAllTasks(); 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 }); } }); // 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 ========== // Save a mining area claim app.post('/api/mining-areas', (req, res) => { try { const { turtleId, bounds } = req.body; db.saveMiningArea(turtleId, bounds); const areas = db.getMiningAreas(); broadcastToClients({ type: 'mining_areas_updated', areas }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get all active mining areas app.get('/api/mining-areas', (req, res) => { try { const areas = db.getMiningAreas(); res.json({ areas }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Close a mining area 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 }); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========== STATISTICS ENDPOINTS ========== // Get server statistics app.get('/api/stats', (req, res) => { try { const stats = { activeTurtles: turtleData.size, totalBlocks: 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); res.json({ stats }); } 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 }); } 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 ========== // 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(); // Enhance with member count const groupsWithMembers = groups.map(group => ({ ...group, members: db.getGroupMembers(group.id) })); res.json({ groups: groupsWithMembers }); } 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) { if (turtleData.has(member.turtle_id)) { const turtle = turtleData.get(member.turtle_id); turtle.pendingCommands = turtle.pendingCommands || []; turtle.pendingCommands.push({ command, param, timestamp: Date.now() }); successCount++; } } 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 }); } }); // 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`); });