/** * Turtle - Server-side turtle wrapper with state machine and command correlation * * Each connected turtle gets a Turtle instance that manages: * - State machine (idle, mining, exploring, etc.) * - UUID-based command-response correlation * - Position/facing/inventory tracking * - Event emission for real-time UI updates * * Inspired by runi95/turtle-control-panel Turtle class. */ import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; import { IdleState, MovingState, MiningState, ExploringState, GoHomeState, RefuelingState, DumpInventoryState, FarmingState, } from './states/index.js'; const STATE_MAP = { idle: IdleState, moving: MovingState, mining: MiningState, exploring: ExploringState, goHome: GoHomeState, returning: GoHomeState, refueling: RefuelingState, dumpInventory: DumpInventoryState, dumping: DumpInventoryState, farming: FarmingState, }; // Timeout for exec() commands (ms) const EXEC_TIMEOUT = 30000; export class Turtle extends EventEmitter { /** * @param {number} id - The ComputerCraft turtle ID * @param {Object} server - Reference to server context (worldBlocks, broadcastToClients, etc.) */ constructor(id, server) { super(); this.id = id; this.server = server; // Turtle state this._position = null; this._homePosition = null; this._facing = 0; this._fuel = 0; this._inventory = {}; this._label = null; // State machine this._state = null; this._stateRunner = null; // The running async generator loop // Command correlation this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout } this._messageIndex = 0; // Connection tracking this.connected = false; this.lastUpdate = Date.now(); this.lastStatusBroadcast = 0; // Legacy command queue (for pocket computer compatibility) this.pendingLegacyCommands = []; // Start in idle state this.setState('idle'); } // ========== Property Getters/Setters with Change Events ========== get position() { return this._position; } set position(value) { const changed = !this._position || this._position.x !== value?.x || this._position.y !== value?.y || this._position.z !== value?.z; this._position = value; if (changed) this._emitUpdate(); } get homePosition() { return this._homePosition; } set homePosition(value) { this._homePosition = value; this._emitUpdate(); } get facing() { return this._facing; } set facing(value) { this._facing = value; this._emitUpdate(); } get fuel() { return this._fuel; } set fuel(value) { this._fuel = value; } get inventory() { return this._inventory; } set inventory(value) { this._inventory = value; this._emitUpdate(); } get stateName() { return this._state?.name || 'idle'; } get stateDescription() { return this._state?.description || 'Idle'; } // ========== State Machine ========== /** * Set the turtle's state. Cancels the current state and starts the new one. * @param {string} stateName - The state name (e.g., 'mining', 'idle') * @param {Object} data - State-specific initialization data */ setState(stateName, data = {}) { // Cancel current state if (this._state) { this._state.cancel(); } // Create new state const StateClass = STATE_MAP[stateName]; if (!StateClass) { console.error(`[Turtle ${this.id}] Unknown state: ${stateName}`); return; } this._state = new StateClass(this, data); console.log(`[Turtle ${this.id}] State: ${this._state.name} - ${this._state.description}`); // Run the state's act() generator loop this._runStateLoop(); this._emitUpdate(); } /** * Run the current state's act() generator in a loop */ async _runStateLoop() { const state = this._state; try { const generator = state.act(); for await (const _ of generator) { // Check if state was cancelled/changed while running if (this._state !== state) { return; // State changed, stop this generator } } } catch (error) { if (this._state === state) { console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message); this.setState('idle'); } } } // ========== Command Execution (UUID-based) ========== /** * Execute Lua code on the turtle and return the result. * Uses UUID-based command-response correlation. * * @param {string} luaCode - The Lua code to execute * @param {number} timeout - Timeout in ms (default: 30s) * @returns {Promise} The result of the Lua execution */ exec(luaCode, timeout = EXEC_TIMEOUT) { return new Promise((resolve, reject) => { const uuid = randomUUID(); const messageIndex = this._messageIndex++; // Set up timeout const timer = setTimeout(() => { this._pendingCommands.delete(uuid); reject(new Error(`Command timed out after ${timeout}ms`)); }, timeout); // Store the pending command this._pendingCommands.set(uuid, { resolve: (result) => { clearTimeout(timer); this._pendingCommands.delete(uuid); resolve(result); }, reject: (error) => { clearTimeout(timer); this._pendingCommands.delete(uuid); reject(error); }, timeout: timer, luaCode, sentAt: Date.now(), }); // Send the command to the turtle (via webbridge modem relay) this._sendExecCommand(uuid, messageIndex, luaCode); }); } /** * Send an eval command to the turtle */ _sendExecCommand(uuid, index, luaCode) { const command = { type: 'eval', uuid, index, code: luaCode, target: this.id, }; // Emit to server for forwarding to webbridge this.emit('sendCommand', command); } /** * Handle a response from the turtle (matched by UUID) */ handleResponse(uuid, result, error) { const pending = this._pendingCommands.get(uuid); if (!pending) { // Response for unknown/expired command - ignore return false; } if (error) { pending.reject(new Error(error)); } else { pending.resolve(result); } return true; } // ========== Position Tracking ========== updatePositionForward() { if (!this._position) return; const pos = { ...this._position }; if (this._facing === 0) pos.z -= 1; else if (this._facing === 1) pos.x += 1; else if (this._facing === 2) pos.z += 1; else if (this._facing === 3) pos.x -= 1; this.position = pos; } updatePositionUp() { if (!this._position) return; this.position = { ...this._position, y: this._position.y + 1 }; } updatePositionDown() { if (!this._position) return; this.position = { ...this._position, y: this._position.y - 1 }; } /** * Get the world position of a block in a given direction from the turtle */ getBlockPositionInDirection(direction) { if (!this._position) return null; const pos = { ...this._position }; if (direction === 'up') { pos.y += 1; } else if (direction === 'down') { pos.y -= 1; } else if (direction === 'forward') { if (this._facing === 0) pos.z -= 1; else if (this._facing === 1) pos.x += 1; else if (this._facing === 2) pos.z += 1; else if (this._facing === 3) pos.x -= 1; } return pos; } /** * Process scan results and update world blocks on the server */ processScanResults(scanResult) { if (!scanResult || !this._position) return; const blocks = []; // Handle directional scan results for (const [dir, blockData] of Object.entries(scanResult)) { let blockPos; if (dir === 'up') { blockPos = { x: this._position.x, y: this._position.y + 1, z: this._position.z }; } else if (dir === 'down') { blockPos = { x: this._position.x, y: this._position.y - 1, z: this._position.z }; } else if (dir === 'forward') { blockPos = this.getBlockPositionInDirection('forward'); } else if (dir.startsWith('h')) { // Horizontal direction by facing number (h0, h1, h2, h3) const facingNum = parseInt(dir.substring(1)); const tempPos = { ...this._position }; if (facingNum === 0) tempPos.z -= 1; else if (facingNum === 1) tempPos.x += 1; else if (facingNum === 2) tempPos.z += 1; else if (facingNum === 3) tempPos.x -= 1; blockPos = tempPos; } if (blockPos && blockData && blockData.name) { blocks.push({ x: blockPos.x, y: blockPos.y, z: blockPos.z, name: blockData.name, metadata: blockData.metadata || 0, discoveredBy: this.id, }); } } // Store blocks in server world map if (blocks.length > 0) { this.emit('blocksDiscovered', blocks); } } // ========== Status & Serialization ========== /** * Update from a legacy status message (modem protocol) */ updateFromStatus(statusData) { this.lastUpdate = Date.now(); this.connected = true; if (statusData.position) { this._position = statusData.position; } if (statusData.homePosition) { this._homePosition = statusData.homePosition; } if (statusData.facing !== undefined) { this._facing = statusData.facing; } if (statusData.fuel !== undefined) { this._fuel = statusData.fuel; } if (statusData.inventory) { this._inventory = statusData.inventory; } this._emitUpdate(); } /** * Get serializable turtle data for clients */ toJSON() { return { turtleID: this.id, position: this._position, homePosition: this._homePosition, facing: this._facing, fuel: this._fuel, inventory: this._inventory, inventoryCount: Array.isArray(this._inventory) ? this._inventory.length : Object.keys(this._inventory).length, mode: this.stateName, state: this.stateName, stateDescription: this.stateDescription, connected: this.connected, lastUpdate: this.lastUpdate, label: this._label, }; } /** * Emit an update event for broadcasting to clients */ _emitUpdate() { // Throttle updates to max once per 100ms const now = Date.now(); if (now - this.lastStatusBroadcast < 100) return; this.lastStatusBroadcast = now; this.emit('update', this.toJSON()); } /** * Clean up when turtle disconnects */ disconnect() { this.connected = false; // Cancel current state if (this._state) { this._state.cancel(); } // Reject all pending commands for (const [uuid, pending] of this._pendingCommands) { pending.reject(new Error('Turtle disconnected')); } this._pendingCommands.clear(); this.emit('disconnect', this.id); } }