feat: Add Turtle class to manage state machine and command execution for server-side turtles

This commit is contained in:
MayaTheShy
2026-02-20 01:46:39 -05:00
parent bdc4dade9f
commit 0e41ee12c5

438
server/Turtle.js Normal file
View 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);
}
}