feat: Add Turtle class to manage state machine and command execution for server-side turtles
This commit is contained in:
438
server/Turtle.js
Normal file
438
server/Turtle.js
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user