/** * 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 with promise chaining * - Position/facing/inventory tracking * - Event emission for real-time UI updates * - Turn-to-direction, equip, rename, inventory transfer * - External inventory interaction (connectToInventory) * - Block deletion on movement * - State recovery on reconnection * * 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, ScanState, ExtractionState, BuildingState, AutocraftState, } from './states/index.js'; // Random name list for unnamed turtles (inspired by reference) const TURTLE_NAMES = [ 'Atlas', 'Bolt', 'Chip', 'Dash', 'Echo', 'Flint', 'Gizmo', 'Hex', 'Iron', 'Jazz', 'Kit', 'Lumen', 'Miko', 'Nova', 'Onyx', 'Pixel', 'Quartz', 'Rust', 'Spark', 'Terra', 'Volt', 'Widget', 'Xenon', 'Zinc', 'Amber', 'Blaze', 'Cobalt', 'Drake', 'Ember', 'Forge', 'Granite', 'Haze', 'Ingot', 'Jasper', 'Kindle', 'Lapis', 'Marble', 'Nickel', 'Opal', 'Prism', ]; const STATE_MAP = { idle: IdleState, moving: MovingState, mining: MiningState, exploring: ExploringState, goHome: GoHomeState, returning: GoHomeState, refueling: RefuelingState, dumpInventory: DumpInventoryState, dumping: DumpInventoryState, farming: FarmingState, scanning: ScanState, scan: ScanState, extracting: ExtractionState, extraction: ExtractionState, building: BuildingState, build: BuildingState, autocrafting: AutocraftState, autocraft: AutocraftState, }; // Timeout for exec() commands (ms) const EXEC_TIMEOUT = 30000; // Direction enum matching reference: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X) const DIRECTION = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3 }; 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._fuelLimit = 100000; this._inventory = {}; this._selectedSlot = 1; this._label = null; this._peripherals = {}; // { side: { types: [...], data: {...} } } this._error = null; this._warning = null; // State machine this._state = null; this._stateRunner = null; // The running async generator loop // Command correlation with promise chaining (prevents concurrent commands racing) this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout } this._messageIndex = 0; this._lastPromise = Promise.resolve(); // Promise chain for sequential exec // Fuel efficiency tracking this._stepsSinceLastRefuel = 0; this._totalSteps = 0; this._totalFuelUsed = 0; // Connection tracking this.connected = false; this.lastUpdate = Date.now(); this.lastStatusBroadcast = 0; // Command queue for eval commands sent to webbridge this.pendingCommands = []; // 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 peripherals() { return this._peripherals; } set peripherals(value) { this._peripherals = value; this._emitUpdate(); } get selectedSlot() { return this._selectedSlot; } set selectedSlot(value) { this._selectedSlot = value; this._emitUpdate(); } get fuelLimit() { return this._fuelLimit; } set fuelLimit(value) { this._fuelLimit = value; } get label() { return this._label; } set label(value) { this._label = value; this._emitUpdate(); } get error() { return this._error; } set error(value) { this._error = value; if (value) this._emitUpdate(); } get warning() { return this._warning; } set warning(value) { this._warning = value; if (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}`); // Persist state for recovery (skip idle to avoid DB noise) if (stateName !== 'idle' && this.server?.db) { try { this.server.db.saveTurtleState(this.id, stateName, data); } catch (e) { // Ignore errors in state persistence } } else if (stateName === 'idle' && this.server?.db) { try { this.server.db.deleteTurtleState(this.id); } catch (e) { /* ignore */ } } // Run the state's act() generator loop this._runStateLoop(); this._emitUpdate(); } /** * Recover state from database on reconnection */ recoverState() { if (!this.server?.db) return false; try { const saved = this.server.db.getTurtleState(this.id); if (saved && saved.stateName && saved.stateName !== 'idle') { const StateClass = STATE_MAP[saved.stateName]; if (StateClass) { console.log(`[Turtle ${this.id}] Recovering state: ${saved.stateName}`); this.setState(saved.stateName, saved.stateData || {}); return true; } } } catch (e) { console.error(`[Turtle ${this.id}] Error recovering state:`, e.message); } return false; } /** * Run the current state's act() generator in a loop */ async _runStateLoop(consecutiveErrors = 0) { const state = this._state; const MAX_CONSECUTIVE_ERRORS = 5; 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 } // Reset error counter on successful iteration consecutiveErrors = 0; } } catch (error) { if (this._state !== state) return; consecutiveErrors++; const isTimeout = error.message?.includes('timed out'); if (isTimeout && consecutiveErrors < MAX_CONSECUTIVE_ERRORS) { console.warn(`[Turtle ${this.id}] Timeout in ${state.name} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}), retrying...`); // Wait a bit then restart the state loop, passing the error count await new Promise(r => setTimeout(r, 2000)); if (this._state === state) { this._runStateLoop(consecutiveErrors); } } else { console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message); // Don't transition idle→idle (causes infinite loop) if (state.name !== 'idle') { this.setState('idle'); } else { // Already idle and erroring — just wait and retry the idle loop console.log(`[Turtle ${this.id}] Idle loop paused, will retry in 60s`); await new Promise(r => setTimeout(r, 60000)); if (this._state === state) { this._runStateLoop(0); } } } } } // ========== Command Execution (UUID-based with Promise Chaining) ========== /** * Execute Lua code on the turtle and return the result. * Uses UUID-based command-response correlation. * Commands are chained sequentially via lastPromise to prevent race conditions. * * @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) { // Chain through lastPromise to ensure sequential execution const execPromise = new Promise((resolve, reject) => { this._lastPromise.catch(() => {}).finally(() => { const uuid = randomUUID(); const messageIndex = this._messageIndex++; // Set up timeout const timer = setTimeout(() => { const pending = this._pendingCommands.get(uuid); if (pending) { console.warn(`[Turtle ${this.id}] ⏰ Command timed out uuid:${uuid.substring(0, 8)} (pending: ${this._pendingCommands.size}, code: ${luaCode.substring(0, 50)})`); } 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); }); }); this._lastPromise = execPromise; return execPromise; } /** * 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); } // ========== Turtle API Methods ========== /** * Turn the turtle to face a specific cardinal direction. * Calculates the optimal turn direction (left or right). * @param {number} direction - 0=North, 1=East, 2=South, 3=West */ async turnToDirection(direction) { if (this._facing === direction) return; const turn = (direction - this._facing + 4) % 4; if (turn === 1) { await this.exec(`turtle.turnRight(); _G._turtleFacing = ${direction}`); this._facing = direction; this._emitUpdate(); } else if (turn === 2) { await this.exec(`turtle.turnLeft(); turtle.turnLeft(); _G._turtleFacing = ${direction}`); this._facing = direction; this._emitUpdate(); } else if (turn === 3) { await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${direction}`); this._facing = direction; this._emitUpdate(); } } /** * Turn the turtle left */ async turnLeft() { const result = await this.exec('return turtle.turnLeft()'); if (result === true || (Array.isArray(result) && result[0] === true)) { this._facing = (this._facing + 3) % 4; // Sync facing on turtle side this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {}); this._emitUpdate(); } return result; } /** * Turn the turtle right */ async turnRight() { const result = await this.exec('return turtle.turnRight()'); if (result === true || (Array.isArray(result) && result[0] === true)) { this._facing = (this._facing + 1) % 4; // Sync facing on turtle side this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {}); this._emitUpdate(); } return result; } /** * Move the turtle forward. Updates position and deletes block at destination. */ async forward() { const result = await this.exec('return turtle.forward()'); if (result === true || (Array.isArray(result) && result[0] === true)) { this.updatePositionForward(); this._deleteBlockAtPosition(this._position); this._stepsSinceLastRefuel++; this._totalSteps++; } return result; } /** * Move the turtle backward. */ async back() { const result = await this.exec('return turtle.back()'); if (result === true || (Array.isArray(result) && result[0] === true)) { // Update position for backward movement if (this._position) { 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; this._deleteBlockAtPosition(pos); } this._stepsSinceLastRefuel++; this._totalSteps++; } return result; } /** * Move the turtle up. Updates position and deletes block at destination. */ async up() { const result = await this.exec('return turtle.up()'); if (result === true || (Array.isArray(result) && result[0] === true)) { this.updatePositionUp(); this._deleteBlockAtPosition(this._position); this._stepsSinceLastRefuel++; this._totalSteps++; } return result; } /** * Move the turtle down. Updates position and deletes block at destination. */ async down() { const result = await this.exec('return turtle.down()'); if (result === true || (Array.isArray(result) && result[0] === true)) { this.updatePositionDown(); this._deleteBlockAtPosition(this._position); this._stepsSinceLastRefuel++; this._totalSteps++; } return result; } /** * Dig the block in front */ async dig() { const result = await this.exec('return turtle.dig()'); if (result === true || (Array.isArray(result) && result[0] === true)) { const pos = this.getBlockPositionInDirection('forward'); if (pos) this._deleteBlockAtPosition(pos); } return result; } /** * Dig the block above */ async digUp() { const result = await this.exec('return turtle.digUp()'); if (result === true || (Array.isArray(result) && result[0] === true)) { const pos = this.getBlockPositionInDirection('up'); if (pos) this._deleteBlockAtPosition(pos); } return result; } /** * Dig the block below */ async digDown() { const result = await this.exec('return turtle.digDown()'); if (result === true || (Array.isArray(result) && result[0] === true)) { const pos = this.getBlockPositionInDirection('down'); if (pos) this._deleteBlockAtPosition(pos); } return result; } /** * Select an inventory slot */ async select(slot = 1) { const result = await this.exec(`return turtle.select(${slot})`); if (result === true || (Array.isArray(result) && result[0] === true)) { this._selectedSlot = slot; } return result; } /** * Transfer items from current slot to another slot */ async transferTo(slot, count) { const cmd = count != null ? `return turtle.transferTo(${slot}, ${count})` : `return turtle.transferTo(${slot})`; return this.exec(cmd); } /** * Transfer items between inventory slots (select from, transferTo target, reselect) */ async inventoryTransfer(fromSlot, toSlot, count) { const currentSlot = this._selectedSlot; await this.select(fromSlot); await this.transferTo(toSlot, count); await this.select(currentSlot); } /** * Drop items from current slot (front direction) */ async drop(count) { const cmd = count != null ? `return turtle.drop(${count})` : 'return turtle.drop()'; const result = await this.exec(cmd); // Auto-refresh adjacent inventory if present if (this._peripherals?.front?.types?.includes('inventory')) { try { await this.connectToInventory('front'); } catch (e) { /* ignore */ } } return result; } /** * Drop items upward */ async dropUp(count) { const cmd = count != null ? `return turtle.dropUp(${count})` : 'return turtle.dropUp()'; const result = await this.exec(cmd); if (this._peripherals?.top?.types?.includes('inventory')) { try { await this.connectToInventory('top'); } catch (e) { /* ignore */ } } return result; } /** * Drop items downward */ async dropDown(count) { const cmd = count != null ? `return turtle.dropDown(${count})` : 'return turtle.dropDown()'; const result = await this.exec(cmd); if (this._peripherals?.bottom?.types?.includes('inventory')) { try { await this.connectToInventory('bottom'); } catch (e) { /* ignore */ } } return result; } /** * Suck items from front */ async suck(count) { const cmd = count != null ? `return turtle.suck(${count})` : 'return turtle.suck()'; return this.exec(cmd); } /** * Suck items from above */ async suckUp(count) { const cmd = count != null ? `return turtle.suckUp(${count})` : 'return turtle.suckUp()'; return this.exec(cmd); } /** * Suck items from below */ async suckDown(count) { const cmd = count != null ? `return turtle.suckDown(${count})` : 'return turtle.suckDown()'; return this.exec(cmd); } /** * Place block in front */ async place(text) { const cmd = text ? `return turtle.place("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.place()'; const result = await this.exec(cmd); if (result === true || (Array.isArray(result) && result[0] === true)) { await this.inspect(); // Auto-inspect to update world map } return result; } /** * Place block above */ async placeUp(text) { const cmd = text ? `return turtle.placeUp("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeUp()'; const result = await this.exec(cmd); if (result === true || (Array.isArray(result) && result[0] === true)) { await this.inspectUp(); } return result; } /** * Place block below */ async placeDown(text) { const cmd = text ? `return turtle.placeDown("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeDown()'; const result = await this.exec(cmd); if (result === true || (Array.isArray(result) && result[0] === true)) { await this.inspectDown(); } return result; } /** * Inspect block in front and update world map */ async inspect() { const result = await this.exec('local h, d = turtle.inspect(); if h then return d else return nil end'); if (result && result.name) { const pos = this.getBlockPositionInDirection('forward'); if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]); } return result; } /** * Inspect block above */ async inspectUp() { const result = await this.exec('local h, d = turtle.inspectUp(); if h then return d else return nil end'); if (result && result.name) { const pos = this.getBlockPositionInDirection('up'); if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]); } return result; } /** * Inspect block below */ async inspectDown() { const result = await this.exec('local h, d = turtle.inspectDown(); if h then return d else return nil end'); if (result && result.name) { const pos = this.getBlockPositionInDirection('down'); if (pos) this.emit('blocksDiscovered', [{ ...pos, name: result.name, metadata: result.metadata || 0, discoveredBy: this.id }]); } return result; } /** * Batched 3-direction inspect (up/down/forward) in a single eval call. * More efficient than 3 separate calls. */ async explore() { const result = await this.exec(` local results = {} local h, d h, d = turtle.inspect() if h then results.forward = d end h, d = turtle.inspectUp() if h then results.up = d end h, d = turtle.inspectDown() if h then results.down = d end return results `); if (result && typeof result === 'object') { this.processScanResults(result); } return result; } /** * Refuel the turtle */ async refuel(count) { const cmd = count != null ? `return turtle.refuel(${count})` : 'return turtle.refuel()'; const fuelBefore = this._fuel; const result = await this.exec(cmd); // Update fuel level const fuelLevel = await this.exec('return turtle.getFuelLevel()'); if (fuelLevel != null) { this._totalFuelUsed += (fuelLevel - fuelBefore); this._fuel = fuelLevel; this._stepsSinceLastRefuel = 0; } return result; } /** * Craft using workbench peripheral */ async craft() { const result = await this.exec(` local hasWorkbench = peripheral.find("workbench") ~= nil if not hasWorkbench then return false, "No workbench" end return peripheral.find("workbench").craft() `); return result; } /** * Equip an item on the left side of the turtle */ async equipLeft() { return this.exec('return turtle.equipLeft()'); } /** * Equip an item on the right side of the turtle */ async equipRight() { return this.exec('return turtle.equipRight()'); } /** * Rename the turtle (set computer label) */ async rename(name) { // Escape quotes and backslashes for safe Lua string interpolation const safeName = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); await this.exec(`os.setComputerLabel("${safeName}")`); this._label = name; this._emitUpdate(); } /** * Auto-assign a random name if the turtle has no label */ autoName() { if (!this._label) { const name = TURTLE_NAMES[Math.floor(Math.random() * TURTLE_NAMES.length)]; this._label = name; // Send rename command to turtle when connected if (this.connected) { this.rename(name).catch(() => {}); } return name; } return this._label; } /** * GPS locate */ async gpsLocate() { const result = await this.exec('local x,y,z = gps.locate(5); if x then return {x=x, y=y, z=z} else return nil end'); if (result && result.x != null) { this.position = { x: Math.floor(result.x), y: Math.floor(result.y), z: Math.floor(result.z) }; } return this._position; } /** * Write content to a file on the turtle's filesystem * @param {string} path - The file path on the turtle (e.g., 'log.txt', 'scripts/miner.lua') * @param {string} content - The content to write * @param {boolean} append - Whether to append instead of overwrite */ async writeToFile(path, content, append = false) { const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // Encode content as base64-like hex to avoid Lua string escaping issues const mode = append ? 'a' : 'w'; // Split content into chunks to avoid oversized eval commands const chunkSize = 4000; const chunks = []; for (let i = 0; i < content.length; i += chunkSize) { chunks.push(content.substring(i, i + chunkSize)); } if (chunks.length <= 1) { // Single write for small files const safeContent = (chunks[0] || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, ''); return this.exec(` local f = fs.open("${safePath}", "${mode}") if f then f.write("${safeContent}") f.close() return true end return false, "Cannot open file" `); } // Multi-chunk write for large files for (let i = 0; i < chunks.length; i++) { const safeChunk = chunks[i].replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, ''); const writeMode = i === 0 ? mode : 'a'; const result = await this.exec(` local f = fs.open("${safePath}", "${writeMode}") if f then f.write("${safeChunk}") f.close() return true end return false, "Cannot open file" `); if (result !== true) return result; } return true; } /** * Refresh detailed inventory state for all 16 slots. * Gets name, count, damage, and maxCount for each slot. */ async refreshInventoryState() { const result = await this.exec(` local inv = {} for slot = 1, 16 do local item = turtle.getItemDetail(slot) if item then inv[tostring(slot)] = { name = item.name, count = item.count, damage = item.damage or 0, maxCount = turtle.getItemSpace(slot) + item.count } end end return inv `); if (result && typeof result === 'object') { this._inventory = result; this._emitUpdate(); } return result; } /** * Connect to an adjacent inventory peripheral and read its contents */ async connectToInventory(side) { const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const result = await this.exec(` local size = peripheral.call("${safeSide}", "size") local content = peripheral.call("${safeSide}", "list") if next(content) == nil then content = nil end return {size = size, content = content} `); if (result) { this._peripherals = { ...this._peripherals, [side]: { ...this._peripherals[side], data: { size: result.size, content: result.content, }, }, }; this._emitUpdate(); } return result; } /** * Update all attached peripherals (scan what's connected) */ async updateAllAttachedPeripherals() { const result = await this.exec(` local peripherals = {} local names = peripheral.getNames() for _, name in ipairs(names) do local types = {peripheral.getType(name)} peripherals[name] = {types = types} end return peripherals `); if (result && typeof result === 'object') { this._peripherals = result; this._emitUpdate(); } return result; } /** * Use a peripheral method by side */ async usePeripheralWithSide(side, method, ...args) { const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safeMethod = method.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const argsStr = args.map(a => { if (a === null) return 'nil'; if (typeof a === 'string') return `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; return String(a); }).join(', '); return this.exec(`return peripheral.call("${safeSide}", "${safeMethod}"${argsStr ? ', ' + argsStr : ''})`); } /** * Sleep for a duration (on the turtle side) */ async sleep(seconds) { return this.exec(`sleep(${seconds})`); } // ========== Real-time Event Handling ========== /** * Handle real-time events from the turtle (inventory, peripherals, etc.) * These come via the webbridge as separate event messages. */ handleEvent(eventType, eventData) { switch (eventType) { case 'INVENTORY_UPDATE': this._inventory = eventData; this._emitUpdate(); break; case 'PERIPHERAL_ATTACHED': // Merge new peripherals this._peripherals = { ...this._peripherals, ...eventData }; this._emitUpdate(); break; case 'PERIPHERAL_DETACHED': // eventData is the side name string if (typeof eventData === 'string' && this._peripherals[eventData]) { const { [eventData]: _, ...rest } = this._peripherals; this._peripherals = rest; this._emitUpdate(); } break; default: console.log(`[Turtle ${this.id}] Unknown event type: ${eventType}`); } } /** * Check if turtle has a peripheral with the given type name */ hasPeripheral(typeName) { return Object.values(this._peripherals).some(p => p.types && p.types.includes(typeName) ); } /** * Get the chunk coordinates for this turtle's position */ get chunk() { if (!this._position) return null; return [Math.floor(this._position.x / 16), Math.floor(this._position.z / 16)]; } /** * 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; } /** * Delete a block from both in-memory world map and database (turtle moved into it = now air) */ _deleteBlockAtPosition(pos) { if (!pos) return; const key = `${pos.x},${pos.y},${pos.z}`; if (this.server?.worldBlocks) { this.server.worldBlocks.delete(key); } if (this.server?.db) { try { this.server.db.deleteBlock(pos.x, pos.y, pos.z); } catch (e) { /* ignore */ } } // Broadcast block deletion to web clients if (this.server?.broadcastToClients) { this.server.broadcastToClients({ type: 'block_deleted', x: pos.x, y: pos.y, z: pos.z, }); } } /** * 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; } if (statusData.label) { this._label = statusData.label; } 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, fuelLimit: this._fuelLimit, selectedSlot: this._selectedSlot, 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, peripherals: this._peripherals, error: this._error, warning: this._warning, stepsSinceLastRefuel: this._stepsSinceLastRefuel, totalSteps: this._totalSteps, totalFuelUsed: this._totalFuelUsed, }; } /** * 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); } }