Files
remoteturtle/server/Turtle.js

1137 lines
31 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
// 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() {
const state = this._state;
let consecutiveErrors = 0;
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
await new Promise(r => setTimeout(r, 2000));
if (this._state === state) {
this._runStateLoop(); // Restart the loop with the same state
}
} else {
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
this.setState('idle');
}
}
}
// ========== 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(() => {
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('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();
}
}
/**
* 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;
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;
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);
}
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.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 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) {
// 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;
}
/**
* 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,
};
}
/**
* 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);
}
}