From 0e41ee12c5f35e08f71b92fcae0688c16eff5978 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Fri, 20 Feb 2026 01:46:39 -0500 Subject: [PATCH] feat: Add Turtle class to manage state machine and command execution for server-side turtles --- server/Turtle.js | 438 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 server/Turtle.js diff --git a/server/Turtle.js b/server/Turtle.js new file mode 100644 index 0000000..3f435cd --- /dev/null +++ b/server/Turtle.js @@ -0,0 +1,438 @@ +/** + * 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); + } +}