diff --git a/server/Turtle.js b/server/Turtle.js index 23c162b..1cdd681 100644 --- a/server/Turtle.js +++ b/server/Turtle.js @@ -3,9 +3,13 @@ * * Each connected turtle gets a Turtle instance that manages: * - State machine (idle, mining, exploring, etc.) - * - UUID-based command-response correlation + * - 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. */ @@ -26,6 +30,15 @@ import { 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, @@ -50,6 +63,9 @@ const STATE_MAP = { // 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 @@ -77,9 +93,10 @@ export class Turtle extends EventEmitter { this._state = null; this._stateRunner = null; // The running async generator loop - // Command correlation + // 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 // Connection tracking this.connected = false; @@ -159,6 +176,7 @@ export class Turtle extends EventEmitter { set selectedSlot(value) { this._selectedSlot = value; + this._emitUpdate(); } get fuelLimit() { @@ -169,6 +187,15 @@ export class Turtle extends EventEmitter { this._fuelLimit = value; } + get label() { + return this._label; + } + + set label(value) { + this._label = value; + this._emitUpdate(); + } + get error() { return this._error; } @@ -218,11 +245,46 @@ export class Turtle extends EventEmitter { 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 */ @@ -246,47 +308,54 @@ export class Turtle extends EventEmitter { } } - // ========== Command Execution (UUID-based) ========== + // ========== 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) { - return new Promise((resolve, reject) => { - const uuid = randomUUID(); - const messageIndex = this._messageIndex++; + // 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(() => { - this._pendingCommands.delete(uuid); - reject(new Error(`Command timed out after ${timeout}ms`)); - }, timeout); + // 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(), + // 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 the command to the turtle (via webbridge modem relay) - this._sendExecCommand(uuid, messageIndex, luaCode); }); + + this._lastPromise = execPromise; + return execPromise; } /** @@ -305,6 +374,458 @@ export class Turtle extends EventEmitter { 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('return turtle.turnRight()'); + this._facing = direction; + this._emitUpdate(); + } else if (turn === 2) { + await this.exec('turtle.turnLeft(); return turtle.turnLeft()'); + this._facing = direction; + this._emitUpdate(); + } else if (turn === 3) { + await this.exec('return turtle.turnLeft()'); + this._facing = direction; + this._emitUpdate(); + } + } + + /** + * 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); + } + 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); + } + } + 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); + } + 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); + } + 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}")` : '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}")` : '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}")` : '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 result = await this.exec(cmd); + // Update fuel level + const fuelLevel = await this.exec('return turtle.getFuelLevel()'); + if (fuelLevel != null) this._fuel = fuelLevel; + 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) { + await this.exec(`os.setComputerLabel("${name}")`); + 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('return gps.locate(5)'); + if (result && typeof result === 'object' && result[1] != null) { + this.position = { x: Math.floor(result[1]), y: Math.floor(result[2]), z: Math.floor(result[3]) }; + } else if (typeof result === 'number') { + // Some versions return x,y,z as separate values + const pos = await this.exec('local x,y,z = gps.locate(5); return {x=x, y=y, z=z}'); + if (pos && pos.x != null) { + this.position = { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) }; + } + } + return this._position; + } + + /** + * Connect to an adjacent inventory peripheral and read its contents + */ + async connectToInventory(side) { + const result = await this.exec(` + local size = peripheral.call("${side}", "size") + local content = peripheral.call("${side}", "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 argsStr = args.map(a => { + if (a === null) return 'nil'; + if (typeof a === 'string') return `"${a}"`; + return String(a); + }).join(', '); + + return this.exec(`return peripheral.call("${side}", "${method}"${argsStr ? ', ' + argsStr : ''})`); + } + + /** + * Sleep for a duration (on the turtle side) + */ + async sleep(seconds) { + return this.exec(`sleep(${seconds})`); + } + // ========== Real-time Event Handling ========== /** @@ -414,6 +935,29 @@ export class Turtle extends EventEmitter { 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 */ @@ -484,6 +1028,9 @@ export class Turtle extends EventEmitter { if (statusData.inventory) { this._inventory = statusData.inventory; } + if (statusData.label) { + this._label = statusData.label; + } this._emitUpdate(); }