feat: Enhance Turtle class with state recovery, command chaining, and new movement methods

This commit is contained in:
MayaTheShy
2026-02-20 02:30:15 -05:00
parent 58cc909de8
commit ac6dd5da95

View File

@@ -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<any>} 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();
}