feat: Add BaseState class for turtle state machine with async generator support

This commit is contained in:
MayaTheShy
2026-02-20 01:41:58 -05:00
parent 9796f73e64
commit 0f22c8e49b

356
server/states/BaseState.js Normal file
View File

@@ -0,0 +1,356 @@
/**
* BaseState - Abstract base class for turtle state machine
*
* Uses async generator pattern inspired by runi95/turtle-control-panel.
* Each state implements *act() which yields control back periodically,
* allowing the state machine to be cooperative and interruptible.
*/
export class BaseState {
/**
* @param {import('../Turtle.js').Turtle} turtle - The turtle instance
* @param {Object} data - State-specific data for initialization/recovery
*/
constructor(turtle, data = {}) {
this.turtle = turtle;
this.data = data;
this.cancelled = false;
this.startedAt = Date.now();
}
/**
* The name of this state (used for logging and UI)
*/
get name() {
return 'base';
}
/**
* Get a human-readable description of what the state is doing
*/
get description() {
return 'Idle';
}
/**
* The async generator that drives the state's behavior.
* Subclasses MUST override this method.
*
* Yield to give up control temporarily (cooperative multitasking).
* Return to signal that the state is complete.
* Throw to signal an error.
*
* @yields {void}
*/
async *act() {
// Base state does nothing - just idles
while (!this.cancelled) {
yield;
await this._sleep(1000);
}
}
/**
* Cancel this state. The act() generator should check this.cancelled
* and clean up gracefully.
*/
cancel() {
this.cancelled = true;
}
/**
* Get recovery data for persisting state across reconnections
*/
getRecoveryData() {
return {
stateName: this.name,
data: this.data,
startedAt: this.startedAt,
};
}
// ========== Helper Methods for Subclasses ==========
/**
* Execute a Lua command on the turtle and wait for the result
*/
async exec(luaCode) {
return this.turtle.exec(luaCode);
}
/**
* Move the turtle forward, digging if necessary
*/
async moveForward(dig = true) {
if (dig) {
const result = await this.exec('turtle.dig(); return turtle.forward()');
if (result) this.turtle.updatePositionForward();
return result;
}
const result = await this.exec('return turtle.forward()');
if (result) this.turtle.updatePositionForward();
return result;
}
/**
* Move the turtle up, digging if necessary
*/
async moveUp(dig = true) {
if (dig) {
const result = await this.exec('turtle.digUp(); return turtle.up()');
if (result) this.turtle.updatePositionUp();
return result;
}
const result = await this.exec('return turtle.up()');
if (result) this.turtle.updatePositionUp();
return result;
}
/**
* Move the turtle down, digging if necessary
*/
async moveDown(dig = true) {
if (dig) {
const result = await this.exec('turtle.digDown(); return turtle.down()');
if (result) this.turtle.updatePositionDown();
return result;
}
const result = await this.exec('return turtle.down()');
if (result) this.turtle.updatePositionDown();
return result;
}
/**
* Turn the turtle to face a specific direction
* @param {number} targetFacing - 0=North, 1=East, 2=South, 3=West
*/
async turnToFace(targetFacing) {
const currentFacing = this.turtle.facing;
if (currentFacing === targetFacing) return true;
const diff = (targetFacing - currentFacing + 4) % 4;
if (diff === 1) {
await this.exec('return turtle.turnRight()');
this.turtle.facing = targetFacing;
} else if (diff === 2) {
await this.exec('turtle.turnRight(); return turtle.turnRight()');
this.turtle.facing = targetFacing;
} else if (diff === 3) {
await this.exec('return turtle.turnLeft()');
this.turtle.facing = targetFacing;
}
return true;
}
/**
* Move to an adjacent point (one of the 6 neighbors)
* Handles turning and digging automatically
*/
async moveToAdjacent(targetPoint) {
const pos = this.turtle.position;
if (!pos) return false;
const dx = targetPoint.x - pos.x;
const dy = targetPoint.y - pos.y;
const dz = targetPoint.z - pos.z;
if (dy === 1) return this.moveUp();
if (dy === -1) return this.moveDown();
// Horizontal movement - determine facing
let targetFacing;
if (dx === 1) targetFacing = 1; // East
else if (dx === -1) targetFacing = 3; // West
else if (dz === 1) targetFacing = 2; // South
else if (dz === -1) targetFacing = 0; // North
else return false; // Not adjacent
await this.turnToFace(targetFacing);
return this.moveForward();
}
/**
* Navigate to a target point using the D* Lite pathfinder
* This is a generator that yields after each step
*/
async *navigateTo(targetPoint, options = {}) {
const { Point, DStarLite } = await import('../pathfinding/index.js');
const start = Point.from(this.turtle.position);
const goal = Point.from(targetPoint);
if (!start || !goal) {
console.error('Cannot navigate: missing position');
return false;
}
if (start.equals(goal)) return true;
const pathfinder = new DStarLite(start, goal, this.turtle.server.worldBlocks, {
canMine: options.canMine !== false,
maxSteps: options.maxSteps || 50000,
});
let currentPos = start;
let attempts = 0;
const maxAttempts = options.maxAttempts || 5000;
while (!currentPos.equals(goal) && attempts < maxAttempts && !this.cancelled) {
attempts++;
const nextStep = pathfinder.getNextStep();
if (!nextStep) {
console.log(`[${this.turtle.id}] No path to ${goal} from ${currentPos}`);
return false;
}
// Scan surroundings before moving
const scanResult = await this.exec(`
local results = {}
local hasBlock, data
hasBlock, data = turtle.inspect()
if hasBlock then results.forward = data end
hasBlock, data = turtle.inspectUp()
if hasBlock then results.up = data end
hasBlock, data = turtle.inspectDown()
if hasBlock then results.down = data end
return results
`);
// Update pathfinder with discovered blocks
if (scanResult && typeof scanResult === 'object') {
this.turtle.processScanResults(scanResult);
// Update pathfinder with new information
for (const [dir, blockData] of Object.entries(scanResult)) {
const blockPos = this.turtle.getBlockPositionInDirection(dir);
if (blockPos) {
pathfinder.updateBlock(Point.from(blockPos), blockData);
}
}
}
// Try to move to the next step
const success = await this.moveToAdjacent(nextStep);
if (success) {
currentPos = Point.from(this.turtle.position);
pathfinder.updateStart(currentPos);
} else {
// Movement failed - update the blocked node and replan
pathfinder.updateBlock(nextStep, { name: 'minecraft:bedrock' }); // Treat as blocked
if (!pathfinder.replan()) {
console.log(`[${this.turtle.id}] Replanning failed - no path exists`);
return false;
}
}
yield; // Cooperative yield
}
return currentPos.equals(goal);
}
/**
* Scan all 6 directions around the turtle
*/
async scanSurroundings() {
const result = await this.exec(`
local results = {}
local facing = _G._turtleFacing or 0
-- Scan up/down
local hasBlock, data
hasBlock, data = turtle.inspectUp()
if hasBlock then results.up = {name=data.name, metadata=data.metadata or 0} end
hasBlock, data = turtle.inspectDown()
if hasBlock then results.down = {name=data.name, metadata=data.metadata or 0} end
-- Scan all 4 horizontal directions
for i = 0, 3 do
hasBlock, data = turtle.inspect()
if hasBlock then
local dir = (facing + i) % 4
results["h" .. dir] = {name=data.name, metadata=data.metadata or 0}
end
if i < 3 then turtle.turnRight(); facing = (facing + 1) % 4 end
end
-- Turn back to original facing
turtle.turnRight()
facing = (facing + 1) % 4
_G._turtleFacing = facing
return results
`);
if (result) {
this.turtle.processScanResults(result);
}
return result;
}
/**
* Check fuel level
*/
async checkFuel() {
const fuel = await this.exec('return turtle.getFuelLevel()');
if (fuel !== null) this.turtle.fuel = fuel;
return fuel;
}
/**
* Get inventory contents
*/
async getInventory() {
const inv = await this.exec(`
local items = {}
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
items[slot] = {name=item.name, count=item.count, damage=item.damage}
end
end
return items
`);
if (inv) this.turtle.inventory = inv;
return inv;
}
/**
* Check if inventory is full
*/
async isInventoryFull() {
const result = await this.exec(`
for slot = 1, 16 do
if turtle.getItemCount(slot) == 0 then return false end
end
return true
`);
return result === true;
}
/**
* Try to refuel from inventory
*/
async tryRefuel() {
return this.exec(`
for slot = 1, 16 do
turtle.select(slot)
if turtle.refuel(0) then
turtle.refuel(1)
turtle.select(1)
return true
end
end
turtle.select(1)
return false
`);
}
/**
* Sleep for a duration (non-blocking)
*/
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}