1252 lines
35 KiB
JavaScript
1252 lines
35 KiB
JavaScript
/**
|
|
* 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<any>} 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);
|
|
}
|
|
}
|