diff --git a/server/states/BaseState.js b/server/states/BaseState.js new file mode 100644 index 0000000..a603208 --- /dev/null +++ b/server/states/BaseState.js @@ -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)); + } +}