feat: Add BaseState class for turtle state machine with async generator support
This commit is contained in:
356
server/states/BaseState.js
Normal file
356
server/states/BaseState.js
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user