439 lines
11 KiB
JavaScript
439 lines
11 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
|
|
* - Position/facing/inventory tracking
|
|
* - Event emission for real-time UI updates
|
|
*
|
|
* 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,
|
|
} from './states/index.js';
|
|
|
|
const STATE_MAP = {
|
|
idle: IdleState,
|
|
moving: MovingState,
|
|
mining: MiningState,
|
|
exploring: ExploringState,
|
|
goHome: GoHomeState,
|
|
returning: GoHomeState,
|
|
refueling: RefuelingState,
|
|
dumpInventory: DumpInventoryState,
|
|
dumping: DumpInventoryState,
|
|
farming: FarmingState,
|
|
};
|
|
|
|
// Timeout for exec() commands (ms)
|
|
const EXEC_TIMEOUT = 30000;
|
|
|
|
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._inventory = {};
|
|
this._label = null;
|
|
|
|
// State machine
|
|
this._state = null;
|
|
this._stateRunner = null; // The running async generator loop
|
|
|
|
// Command correlation
|
|
this._pendingCommands = new Map(); // uuid -> { resolve, reject, timeout }
|
|
this._messageIndex = 0;
|
|
|
|
// Connection tracking
|
|
this.connected = false;
|
|
this.lastUpdate = Date.now();
|
|
this.lastStatusBroadcast = 0;
|
|
|
|
// Legacy command queue (for pocket computer compatibility)
|
|
this.pendingLegacyCommands = [];
|
|
|
|
// 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 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}`);
|
|
|
|
// Run the state's act() generator loop
|
|
this._runStateLoop();
|
|
this._emitUpdate();
|
|
}
|
|
|
|
/**
|
|
* Run the current state's act() generator in a loop
|
|
*/
|
|
async _runStateLoop() {
|
|
const state = this._state;
|
|
|
|
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
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (this._state === state) {
|
|
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
|
|
this.setState('idle');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== Command Execution (UUID-based) ==========
|
|
|
|
/**
|
|
* Execute Lua code on the turtle and return the result.
|
|
* Uses UUID-based command-response correlation.
|
|
*
|
|
* @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++;
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|