diff --git a/src/build-state.js b/src/build-state.js new file mode 100644 index 0000000..8f1977b --- /dev/null +++ b/src/build-state.js @@ -0,0 +1,253 @@ +import { randomUUID } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { log, logError } from './utils.js'; + +const TAG = 'BuildState'; +const BUILDS_DIR = './cache/builds'; + +/** + * Manages persistent build state for pause/resume functionality. + * Splits storage into: + * .meta.json - small, updated per batch (status, progress) + * .commands.json - full command list, written once + */ +export class BuildStateManager { + constructor() { + // Ensure cache dir exists + try { + mkdirSync(BUILDS_DIR, { recursive: true }); + } catch { + // Already exists + } + } + + /** + * Create a new build record. + * @param {object} opts + * @param {string} opts.source - 'blueprint' | 'schematic' | 'shape' + * @param {string} [opts.sourceUrl] + * @param {string} opts.name + * @param {number} opts.originX + * @param {number} opts.originY + * @param {number} opts.originZ + * @param {string[]} opts.commands - Full command list + * @returns {string} buildId + */ + createBuild({ source, sourceUrl, name, originX, originY, originZ, commands }) { + const buildId = randomUUID(); + const now = new Date().toISOString(); + + const meta = { + buildId, + status: 'active', + source, + sourceUrl: sourceUrl || null, + name, + originX, + originY, + originZ, + totalCommands: commands.length, + completedIndex: 0, + succeeded: 0, + failed: 0, + createdAt: now, + updatedAt: now, + }; + + // Write commands file (once) + this._writeJson(`${buildId}.commands.json`, commands); + + // Write meta file + this._writeMeta(buildId, meta); + + log(TAG, `Created build ${buildId}: "${name}" (${commands.length} commands)`); + return buildId; + } + + /** + * Update build progress after a batch completes. + * @param {string} buildId + * @param {number} completedIndex - Index up to which commands have been processed + * @param {number} succeeded + * @param {number} failed + */ + updateProgress(buildId, completedIndex, succeeded, failed) { + const meta = this._readMeta(buildId); + if (!meta) return; + + meta.completedIndex = completedIndex; + meta.succeeded = succeeded; + meta.failed = failed; + meta.updatedAt = new Date().toISOString(); + + this._writeMeta(buildId, meta); + } + + /** + * Mark build as paused. + * @param {string} buildId + * @returns {object|null} Updated meta + */ + pauseBuild(buildId) { + const meta = this._readMeta(buildId); + if (!meta) return null; + + meta.status = 'paused'; + meta.updatedAt = new Date().toISOString(); + this._writeMeta(buildId, meta); + + log(TAG, `Paused build ${buildId} at ${meta.completedIndex}/${meta.totalCommands}`); + return meta; + } + + /** + * Mark build as completed. + * @param {string} buildId + * @param {number} succeeded + * @param {number} failed + */ + completeBuild(buildId, succeeded, failed) { + const meta = this._readMeta(buildId); + if (!meta) return; + + meta.status = 'completed'; + meta.completedIndex = meta.totalCommands; + meta.succeeded = succeeded; + meta.failed = failed; + meta.updatedAt = new Date().toISOString(); + this._writeMeta(buildId, meta); + + // Clean up commands file for completed builds + this._deleteFile(`${buildId}.commands.json`); + + log(TAG, `Completed build ${buildId}: ${succeeded} succeeded, ${failed} failed`); + } + + /** + * Mark build as cancelled. + * @param {string} buildId + * @param {number} succeeded + * @param {number} failed + */ + cancelBuild(buildId, succeeded, failed) { + const meta = this._readMeta(buildId); + if (!meta) return; + + meta.status = 'cancelled'; + meta.succeeded = succeeded; + meta.failed = failed; + meta.updatedAt = new Date().toISOString(); + this._writeMeta(buildId, meta); + + log(TAG, `Cancelled build ${buildId} at ${meta.completedIndex}/${meta.totalCommands}`); + } + + /** + * Get the remaining commands for a paused build. + * @param {string} buildId + * @returns {{ meta: object, commands: string[] } | null} + */ + getResumableCommands(buildId) { + const meta = this._readMeta(buildId); + if (!meta) return null; + if (meta.status !== 'paused') return null; + + const commands = this._readJson(`${buildId}.commands.json`); + if (!commands) return null; + + const remaining = commands.slice(meta.completedIndex); + return { meta, commands: remaining }; + } + + /** + * Get build metadata. + * @param {string} buildId + * @returns {object|null} + */ + getBuild(buildId) { + return this._readMeta(buildId); + } + + /** + * List all builds, optionally filtered by status. + * @param {string} [status] - Filter by status + * @returns {object[]} + */ + listBuilds(status) { + const builds = []; + try { + const files = readdirSync(BUILDS_DIR); + for (const file of files) { + if (file.endsWith('.meta.json')) { + try { + const data = JSON.parse(readFileSync(join(BUILDS_DIR, file), 'utf-8')); + if (!status || data.status === status) { + builds.push(data); + } + } catch { + // Skip corrupted files + } + } + } + } catch { + // Directory doesn't exist yet + } + + // Sort by updatedAt descending + builds.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + return builds; + } + + /** + * Get the most recent paused build. + * @returns {object|null} + */ + getMostRecentPaused() { + const paused = this.listBuilds('paused'); + return paused.length > 0 ? paused[0] : null; + } + + /** + * Get the current active build (if any). + * @returns {object|null} + */ + getActiveBuild() { + const active = this.listBuilds('active'); + return active.length > 0 ? active[0] : null; + } + + // ── Private helpers ── + + _readMeta(buildId) { + return this._readJson(`${buildId}.meta.json`); + } + + _writeMeta(buildId, meta) { + this._writeJson(`${buildId}.meta.json`, meta); + } + + _readJson(filename) { + try { + return JSON.parse(readFileSync(join(BUILDS_DIR, filename), 'utf-8')); + } catch { + return null; + } + } + + _writeJson(filename, data) { + try { + writeFileSync(join(BUILDS_DIR, filename), JSON.stringify(data), 'utf-8'); + } catch (err) { + logError(TAG, `Failed to write ${filename}: ${err.message}`); + } + } + + _deleteFile(filename) { + try { + unlinkSync(join(BUILDS_DIR, filename)); + } catch { + // File may not exist + } + } +} diff --git a/src/command-queue.js b/src/command-queue.js index 0dcfb4f..336bd87 100644 --- a/src/command-queue.js +++ b/src/command-queue.js @@ -31,6 +31,7 @@ export class CommandQueue { this._totalSent = 0; this._totalCompleted = 0; this._cancelBuild = false; + this._pauseBuild = false; } /** @@ -110,21 +111,43 @@ export class CommandQueue { /** * Enqueue a batch with progress reporting. * Calls progressFn with status updates between layer batches. + * Supports pause via pauseBuild() and build state persistence via buildState. * @param {Array<{id: string, message: string}>} commands * @param {(progress: { completed: number, total: number, percent: number }) => void} [progressFn] - * @returns {Promise<{total: number, succeeded: number, failed: number, cancelled: boolean, results: Array}>} + * @param {object} [opts] + * @param {string} [opts.buildId] - Build state ID for persistence + * @param {import('./build-state.js').BuildStateManager} [opts.buildState] - Build state manager + * @param {number} [opts.startIndex=0] - Starting index (for resumed builds) + * @returns {Promise<{total: number, succeeded: number, failed: number, cancelled: boolean, paused: boolean, completedIndex: number, results: Array}>} */ - async enqueueBatchWithProgress(commands, progressFn) { + async enqueueBatchWithProgress(commands, progressFn, opts = {}) { this._cancelBuild = false; + this._pauseBuild = false; const results = []; - let succeeded = 0; + let succeeded = opts.startIndex ? 0 : 0; // Fresh count for this run let failed = 0; + const startIndex = opts.startIndex || 0; for (let i = 0; i < commands.length; i += this.batchSize) { // Check cancellation if (this._cancelBuild) { - log(TAG, `Build cancelled at ${i}/${commands.length} commands`); - return { total: commands.length, succeeded, failed, cancelled: true, results }; + const completedIndex = startIndex + i; + log(TAG, `Build cancelled at ${completedIndex}/${startIndex + commands.length} commands`); + if (opts.buildState && opts.buildId) { + opts.buildState.cancelBuild(opts.buildId, succeeded, failed); + } + return { total: commands.length, succeeded, failed, cancelled: true, paused: false, completedIndex, results }; + } + + // Check pause + if (this._pauseBuild) { + const completedIndex = startIndex + i; + log(TAG, `Build paused at ${completedIndex}/${startIndex + commands.length} commands`); + if (opts.buildState && opts.buildId) { + opts.buildState.updateProgress(opts.buildId, completedIndex, succeeded, failed); + opts.buildState.pauseBuild(opts.buildId); + } + return { total: commands.length, succeeded, failed, cancelled: false, paused: true, completedIndex, results }; } const batch = commands.slice(i, i + this.batchSize); @@ -145,9 +168,16 @@ export class CommandQueue { // Report progress const completed = i + batch.length; - const percent = Math.round((completed / commands.length) * 100); + const totalWithOffset = startIndex + commands.length; + const completedWithOffset = startIndex + completed; + const percent = Math.round((completedWithOffset / totalWithOffset) * 100); if (progressFn) { - progressFn({ completed, total: commands.length, percent }); + progressFn({ completed: completedWithOffset, total: totalWithOffset, percent }); + } + + // Update build state periodically (every 5 batches) + if (opts.buildState && opts.buildId && (i / this.batchSize) % 5 === 4) { + opts.buildState.updateProgress(opts.buildId, startIndex + completed, succeeded, failed); } // Delay between batches (except after the last one) @@ -156,7 +186,7 @@ export class CommandQueue { } } - return { total: commands.length, succeeded, failed, cancelled: false, results }; + return { total: commands.length, succeeded, failed, cancelled: false, paused: false, completedIndex: startIndex + commands.length, results }; } /** Cancel an in-progress build */ @@ -164,6 +194,11 @@ export class CommandQueue { this._cancelBuild = true; } + /** Pause an in-progress build */ + pauseBuild() { + this._pauseBuild = true; + } + /** Process queued commands respecting rate limits */ async _processQueue() { if (this._processing) return; diff --git a/src/fill-optimizer.js b/src/fill-optimizer.js new file mode 100644 index 0000000..f34cbcf --- /dev/null +++ b/src/fill-optimizer.js @@ -0,0 +1,232 @@ +import { log } from './utils.js'; + +const TAG = 'FillOptimizer'; +const MAX_FILL_AXIS = 32; // Bedrock fill command limit per axis + +/** + * Optimize an array of voxels into fill + setblock commands. + * Groups contiguous same-type blocks into fill regions to reduce command count. + * + * Algorithm: Layer-based row scanning with Z-axis merging + * 1. Group voxels by Y layer + * 2. Within each layer, group by block type + * 3. For each block type per layer, group by Z row, find contiguous X runs + * 4. Merge adjacent Z rows with identical X runs into 2D fill regions + * 5. Emit `fill` for multi-block regions, `setblock` for singles + * 6. Split any axis > 32 blocks into chunks (Bedrock limit) + * + * @param {Array<{ x: number, y: number, z: number, blockStr: string }>} blocks + * Pre-resolved blocks with world coordinates and formatted block string + * @returns {string[]} Optimized array of setblock/fill commands + */ +export function optimizeCommands(blocks) { + if (blocks.length === 0) return []; + + // Step 1: Group by Y layer + /** @type {Map>} */ + const layers = new Map(); + for (const b of blocks) { + let layer = layers.get(b.y); + if (!layer) { + layer = []; + layers.set(b.y, layer); + } + layer.push({ x: b.x, z: b.z, blockStr: b.blockStr }); + } + + const commands = []; + + // Process layers bottom-up + const sortedYs = [...layers.keys()].sort((a, b) => a - b); + + for (const y of sortedYs) { + const layer = layers.get(y); + + // Step 2: Group by block type within this layer + /** @type {Map>} */ + const byType = new Map(); + for (const b of layer) { + let list = byType.get(b.blockStr); + if (!list) { + list = []; + byType.set(b.blockStr, list); + } + list.push({ x: b.x, z: b.z }); + } + + for (const [blockStr, positions] of byType) { + // Step 3: Group by Z row, find contiguous X runs + /** @type {Map} z -> sorted x values */ + const byZ = new Map(); + for (const p of positions) { + let xs = byZ.get(p.z); + if (!xs) { + xs = []; + byZ.set(p.z, xs); + } + xs.push(p.x); + } + + // Sort X values within each Z row + for (const xs of byZ.values()) { + xs.sort((a, b) => a - b); + } + + // Find contiguous X runs per Z row + /** @type {Map>} */ + const runsByZ = new Map(); + for (const [z, xs] of byZ) { + const runs = findContiguousRuns(xs); + runsByZ.set(z, runs); + } + + // Step 4: Merge adjacent Z rows with identical X runs + const regions = mergeZRows(runsByZ); + + // Step 5: Emit commands + for (const region of regions) { + // Step 6: Split oversized regions + const chunks = splitRegion(region, y); + for (const chunk of chunks) { + if (chunk.x1 === chunk.x2 && chunk.z1 === chunk.z2 && chunk.y1 === chunk.y2) { + commands.push(`setblock ${chunk.x1} ${chunk.y1} ${chunk.z1} ${blockStr}`); + } else { + commands.push(`fill ${chunk.x1} ${chunk.y1} ${chunk.z1} ${chunk.x2} ${chunk.y2} ${chunk.z2} ${blockStr}`); + } + } + } + } + } + + return commands; +} + +/** + * Find contiguous runs in a sorted array of integers. + * e.g. [1,2,3,5,6,8] -> [{x1:1,x2:3}, {x1:5,x2:6}, {x1:8,x2:8}] + * @param {number[]} sorted + * @returns {Array<{ x1: number, x2: number }>} + */ +function findContiguousRuns(sorted) { + if (sorted.length === 0) return []; + const runs = []; + let x1 = sorted[0]; + let x2 = sorted[0]; + + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === x2 + 1) { + x2 = sorted[i]; + } else { + runs.push({ x1, x2 }); + x1 = sorted[i]; + x2 = sorted[i]; + } + } + runs.push({ x1, x2 }); + return runs; +} + +/** + * Merge adjacent Z rows that have identical X run patterns into 2D fill regions. + * @param {Map>} runsByZ + * @returns {Array<{ x1: number, x2: number, z1: number, z2: number }>} + */ +function mergeZRows(runsByZ) { + if (runsByZ.size === 0) return []; + + const sortedZs = [...runsByZ.keys()].sort((a, b) => a - b); + const regions = []; + + for (const z of sortedZs) { + const runs = runsByZ.get(z); + for (const run of runs) { + // Try to extend an existing region + let merged = false; + for (const region of regions) { + if (region.x1 === run.x1 && region.x2 === run.x2 && region.z2 === z - 1) { + region.z2 = z; + merged = true; + break; + } + } + if (!merged) { + regions.push({ x1: run.x1, x2: run.x2, z1: z, z2: z }); + } + } + } + + return regions; +} + +/** + * Split a 2D region + Y into chunks that fit within Bedrock's 32-block fill limit. + * Since we process one Y layer at a time, y1 === y2. + * @param {{ x1: number, x2: number, z1: number, z2: number }} region + * @param {number} y + * @returns {Array<{ x1: number, x2: number, y1: number, y2: number, z1: number, z2: number }>} + */ +function splitRegion(region, y) { + const chunks = []; + const xLen = region.x2 - region.x1 + 1; + const zLen = region.z2 - region.z1 + 1; + + const xChunks = Math.ceil(xLen / MAX_FILL_AXIS); + const zChunks = Math.ceil(zLen / MAX_FILL_AXIS); + + for (let xi = 0; xi < xChunks; xi++) { + for (let zi = 0; zi < zChunks; zi++) { + const cx1 = region.x1 + xi * MAX_FILL_AXIS; + const cx2 = Math.min(region.x1 + (xi + 1) * MAX_FILL_AXIS - 1, region.x2); + const cz1 = region.z1 + zi * MAX_FILL_AXIS; + const cz2 = Math.min(region.z1 + (zi + 1) * MAX_FILL_AXIS - 1, region.z2); + + chunks.push({ x1: cx1, x2: cx2, y1: y, y2: y, z1: cz1, z2: cz2 }); + } + } + + return chunks; +} + +/** + * Optimize voxels from a blueprint into commands. + * This is the main entry point that replaces the simple setblock loop. + * + * @param {Array<{ x: number, y: number, z: number, matId: string, matName: string|null }>} voxels + * @param {number} originX - World X coordinate for build origin + * @param {number} originY - World Y coordinate for build origin + * @param {number} originZ - World Z coordinate for build origin + * @param {{ x: number, y: number, z: number }} blueprintOrigin - Blueprint's min corner + * @param {(matId: string, matName: string|null) => object} resolveBlockFn + * @param {(resolved: object) => string} formatBlockFn + * @returns {{ commands: string[], materialCounts: Map, skippedAir: number }} + */ +export function optimizeVoxels(voxels, originX, originY, originZ, blueprintOrigin, resolveBlockFn, formatBlockFn) { + const blocks = []; + const materialCounts = new Map(); + let skippedAir = 0; + + for (const voxel of voxels) { + if (voxel.matId === '0' || voxel.matId === 'air') { + skippedAir++; + continue; + } + + const resolved = resolveBlockFn(voxel.matId, voxel.matName); + const blockStr = formatBlockFn(resolved); + + const wx = originX + (voxel.x - blueprintOrigin.x); + const wy = originY + (voxel.y - blueprintOrigin.y); + const wz = originZ + (voxel.z - blueprintOrigin.z); + + blocks.push({ x: wx, y: wy, z: wz, blockStr }); + + const matKey = resolved.name; + materialCounts.set(matKey, (materialCounts.get(matKey) || 0) + 1); + } + + const commands = optimizeCommands(blocks); + + log(TAG, `Optimized ${blocks.length} blocks into ${commands.length} commands (${((1 - commands.length / Math.max(blocks.length, 1)) * 100).toFixed(1)}% reduction)`); + + return { commands, materialCounts, skippedAir }; +} diff --git a/src/grabcraft.js b/src/grabcraft.js index 7d08a57..d92513e 100644 --- a/src/grabcraft.js +++ b/src/grabcraft.js @@ -1,6 +1,7 @@ import { log, logError } from './utils.js'; import { resolveBlock, formatBlock, getUnknownBlocks, clearUnknownBlocks } from './block-map.js'; import { fetchPage as fetchPageBrowser } from './schematics-browser.js'; +import { optimizeVoxels } from './fill-optimizer.js'; const TAG = 'GrabCraft'; @@ -191,43 +192,24 @@ export function blueprintToCommands(blueprint, originX, originY, originZ) { clearUnknownBlocks(); const { voxels, origin } = blueprint; - const commands = []; - const materialCounts = new Map(); - let skippedAir = 0; - // Sort voxels bottom-up (y ascending) for structural integrity - const sorted = [...voxels].sort((a, b) => { - if (a.y !== b.y) return a.y - b.y; - if (a.z !== b.z) return a.z - b.z; - return a.x - b.x; - }); - - for (const voxel of sorted) { - // Skip air blocks - if (voxel.matId === '0' || voxel.matId === 'air') { - skippedAir++; - continue; - } - - const resolved = resolveBlock(voxel.matId, voxel.matName); - const blockStr = formatBlock(resolved); - - // Translate to world coordinates relative to build origin - const wx = originX + (voxel.x - origin.x); - const wy = originY + (voxel.y - origin.y); - const wz = originZ + (voxel.z - origin.z); - - commands.push(`setblock ${wx} ${wy} ${wz} ${blockStr}`); - - const matKey = resolved.name; - materialCounts.set(matKey, (materialCounts.get(matKey) || 0) + 1); - } + // Use fill optimizer for dramatic command reduction + const { commands, materialCounts, skippedAir } = optimizeVoxels( + voxels, + originX, + originY, + originZ, + origin, + resolveBlock, + formatBlock + ); const unknowns = getUnknownBlocks(); const summary = { name: blueprint.name, totalVoxels: voxels.length, + totalBlocks: voxels.length - skippedAir, totalCommands: commands.length, skippedAir, dimensions: blueprint.dimensions, diff --git a/src/mcp-server.js b/src/mcp-server.js index 95a97fc..8ceabae 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -9,6 +9,8 @@ import { searchBlueprints, fetchBlueprint, blueprintToCommands, getCategories } import { searchSchematics, fetchSchematic, getSchematicCategories } from './schematics.js'; import { getAllBlocks } from './block-map.js'; import { SHAPES } from './building-helpers.js'; +import { BuildStateManager } from './build-state.js'; +import { PlayerAutomation } from './player-automation.js'; const TAG = 'MCP'; @@ -36,6 +38,10 @@ export function startMcpServer(bedrock, port = 3002) { const app = express(); app.use(express.json()); + // Shared build state and automation across sessions + const buildState = new BuildStateManager(); + const automation = new PlayerAutomation(bedrock); + // Track active transports by session const transports = {}; @@ -566,6 +572,15 @@ export function startMcpServer(bedrock, port = 3002) { // Build it const prepared = commands.map((line) => createCommandMessage(line)); + // Create build state for persistence + const buildId = buildState.createBuild({ + source: 'blueprint', + sourceUrl: url, + name: summary.name, + originX, originY, originZ, + commands, + }); + const progressFn = (progress) => { try { sendNotification({ @@ -581,9 +596,28 @@ export function startMcpServer(bedrock, port = 3002) { } }; - const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn); + const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn, { + buildId, + buildState, + }); + + if (result.paused) { + return { + content: [{ + type: 'text', + text: `Blueprint "${summary.name}" build paused at ${result.completedIndex}/${commands.length} commands.\nBuild ID: ${buildId}\nUse minecraft_resume_build to continue.`, + }], + }; + } + + if (result.cancelled) { + buildState.cancelBuild(buildId, result.succeeded, result.failed); + } else { + buildState.completeBuild(buildId, result.succeeded, result.failed); + } let text = `Blueprint "${summary.name}" build ${result.cancelled ? 'cancelled' : 'complete'}!\n`; + text += `Build ID: ${buildId}\n`; text += `Blocks placed: ${result.succeeded}/${result.total}\n`; text += `Failed: ${result.failed}\n`; text += `Dimensions: ${summary.dimensions.width}W x ${summary.dimensions.height}H x ${summary.dimensions.depth}D\n`; @@ -726,7 +760,7 @@ export function startMcpServer(bedrock, port = 3002) { 'minecraft_cancel_build', { title: 'Cancel Build', - description: 'Cancel an in-progress build operation (blueprint or shape build).', + description: 'Cancel an in-progress build operation (blueprint, schematic, or shape build). The build state is preserved.', inputSchema: z.object({}), }, async () => { @@ -737,6 +771,300 @@ export function startMcpServer(bedrock, port = 3002) { } ); + // ── Tool: minecraft_pause_build ───────────────────────────────────── + server.registerTool( + 'minecraft_pause_build', + { + title: 'Pause Build', + description: 'Pause an active build operation. The build state is saved and can be resumed later with minecraft_resume_build.', + inputSchema: z.object({}), + }, + async () => { + bedrock.commandQueue.pauseBuild(); + return { + content: [{ type: 'text', text: 'Build pause requested. The current batch will finish before pausing. Use minecraft_resume_build to continue.' }], + }; + } + ); + + // ── Tool: minecraft_resume_build ──────────────────────────────────── + server.registerTool( + 'minecraft_resume_build', + { + title: 'Resume Build', + description: 'Resume a paused build operation. If no buildId is given, resumes the most recently paused build.', + inputSchema: z.object({ + buildId: z.string().optional().describe('Build ID to resume. Omit to resume most recent paused build.'), + }), + }, + async ({ buildId }, { sendNotification }) => { + try { + // Find the build to resume + let targetId = buildId; + if (!targetId) { + const recent = buildState.getMostRecentPaused(); + if (!recent) { + return { + content: [{ type: 'text', text: 'No paused builds found. Use minecraft_list_builds to see all builds.' }], + isError: true, + }; + } + targetId = recent.buildId; + } + + const resumable = buildState.getResumableCommands(targetId); + if (!resumable) { + const build = buildState.getBuild(targetId); + if (!build) { + return { + content: [{ type: 'text', text: `Build ${targetId} not found.` }], + isError: true, + }; + } + return { + content: [{ type: 'text', text: `Build ${targetId} is ${build.status}, not resumable.` }], + isError: true, + }; + } + + const { meta, commands: remaining } = resumable; + + // Mark as active again + meta.status = 'active'; + meta.updatedAt = new Date().toISOString(); + + log('MCP', `Resuming build ${targetId}: "${meta.name}" from ${meta.completedIndex}/${meta.totalCommands} (${remaining.length} remaining)`); + + const prepared = remaining.map((line) => createCommandMessage(line)); + + const progressFn = (progress) => { + try { + sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + logger: 'minecraft-resume', + data: `Resuming "${meta.name}": ${progress.percent}% (${progress.completed}/${progress.total})`, + }, + }); + } catch { /* non-critical */ } + }; + + const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn, { + buildId: targetId, + buildState, + startIndex: meta.completedIndex, + }); + + if (result.paused) { + return { + content: [{ + type: 'text', + text: `Build "${meta.name}" paused again at ${result.completedIndex}/${meta.totalCommands} commands.\nBuild ID: ${targetId}\nUse minecraft_resume_build to continue.`, + }], + }; + } + + if (result.cancelled) { + buildState.cancelBuild(targetId, result.succeeded, result.failed); + } else { + buildState.completeBuild(targetId, result.succeeded, result.failed); + } + + const status = result.cancelled ? 'cancelled' : 'complete'; + return { + content: [{ + type: 'text', + text: `Resumed build "${meta.name}" ${status}!\nBlocks placed (this run): ${result.succeeded}/${result.total}\nFailed: ${result.failed}\nTotal progress: ${result.completedIndex}/${meta.totalCommands}`, + }], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error resuming build: ${err.message}` }], + isError: true, + }; + } + } + ); + + // ── Tool: minecraft_list_builds ───────────────────────────────────── + server.registerTool( + 'minecraft_list_builds', + { + title: 'List Builds', + description: 'List all builds with their status and progress. Optionally filter by status.', + inputSchema: z.object({ + status: z.enum(['active', 'paused', 'completed', 'cancelled']).optional() + .describe('Filter by status'), + }), + }, + async ({ status }) => { + const builds = buildState.listBuilds(status); + + if (builds.length === 0) { + return { + content: [{ type: 'text', text: status ? `No ${status} builds found.` : 'No builds recorded yet.' }], + }; + } + + const formatted = builds.map((b) => { + const percent = Math.round((b.completedIndex / b.totalCommands) * 100); + return `[${b.status.toUpperCase()}] ${b.name}\n ID: ${b.buildId}\n Source: ${b.source}${b.sourceUrl ? ` (${b.sourceUrl})` : ''}\n Progress: ${b.completedIndex}/${b.totalCommands} (${percent}%)\n Origin: ${b.originX}, ${b.originY}, ${b.originZ}\n Updated: ${b.updatedAt}`; + }).join('\n\n'); + + return { + content: [{ type: 'text', text: `${builds.length} build(s):\n\n${formatted}` }], + }; + } + ); + + // ── Tool: minecraft_teleport ──────────────────────────────────────── + server.registerTool( + 'minecraft_teleport', + { + title: 'Teleport Player', + description: 'Teleport a player to specific coordinates.', + inputSchema: z.object({ + x: z.number().describe('X coordinate'), + y: z.number().describe('Y coordinate'), + z: z.number().describe('Z coordinate'), + player: z.string().optional().describe('Target player (default: @p)'), + }), + }, + async ({ x, y, z: zCoord, player }) => { + try { + const response = await automation.teleportTo(x, y, zCoord, player); + const msg = response?.statusMessage || 'Teleported'; + const ok = response?.statusCode === 0; + return { + content: [{ type: 'text', text: `[${ok ? 'OK' : 'ERROR'}] ${msg}` }], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true, + }; + } + } + ); + + // ── Tool: minecraft_give_item ─────────────────────────────────────── + server.registerTool( + 'minecraft_give_item', + { + title: 'Give Item', + description: 'Give items to a player. E.g. give diamond_sword, netherite_pickaxe, golden_apple.', + inputSchema: z.object({ + item: z.string().describe('Item ID, e.g. "diamond_sword", "golden_apple"'), + amount: z.number().int().min(1).max(64).optional().describe('Amount (default 1, max 64)'), + player: z.string().optional().describe('Target player (default: @p)'), + }), + }, + async ({ item, amount, player }) => { + try { + const response = await automation.giveItem(item, amount ?? 1, player); + const msg = response?.statusMessage || 'Item given'; + const ok = response?.statusCode === 0; + return { + content: [{ type: 'text', text: `[${ok ? 'OK' : 'ERROR'}] ${msg}` }], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true, + }; + } + } + ); + + // ── Tool: minecraft_clear_area ────────────────────────────────────── + server.registerTool( + 'minecraft_clear_area', + { + title: 'Clear Area', + description: 'Fill a region with air (clear all blocks). Automatically splits into 32x32x32 chunks for Bedrock compatibility.', + inputSchema: z.object({ + x1: z.number().int().describe('First corner X'), + y1: z.number().int().describe('First corner Y'), + z1: z.number().int().describe('First corner Z'), + x2: z.number().int().describe('Second corner X'), + y2: z.number().int().describe('Second corner Y'), + z2: z.number().int().describe('Second corner Z'), + }), + }, + async ({ x1, y1, z1, x2, y2, z2 }) => { + try { + const result = await automation.clearArea(x1, y1, z1, x2, y2, z2); + return { + content: [{ + type: 'text', + text: `Area cleared: ${result.succeeded}/${result.total} fill commands succeeded, ${result.failed} failed`, + }], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true, + }; + } + } + ); + + // ── Tool: minecraft_scan_area ─────────────────────────────────────── + server.registerTool( + 'minecraft_scan_area', + { + title: 'Scan Area', + description: 'Scan blocks in a region and return a map of non-air block positions and types. Max 1000 blocks per scan.', + inputSchema: z.object({ + x1: z.number().int().describe('First corner X'), + y1: z.number().int().describe('First corner Y'), + z1: z.number().int().describe('First corner Z'), + x2: z.number().int().describe('Second corner X'), + y2: z.number().int().describe('Second corner Y'), + z2: z.number().int().describe('Second corner Z'), + }), + }, + async ({ x1, y1, z1, x2, y2, z2 }) => { + try { + const blocks = await automation.scanBlocks(x1, y1, z1, x2, y2, z2); + + if (blocks.length === 0) { + return { + content: [{ type: 'text', text: 'No non-air blocks found in the scanned area.' }], + }; + } + + // Summarize by block type + const counts = new Map(); + for (const b of blocks) { + counts.set(b.block, (counts.get(b.block) || 0) + 1); + } + + const summary = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([block, count]) => ` ${block}: ${count}`) + .join('\n'); + + const detail = blocks + .map(b => ` ${b.x},${b.y},${b.z}: ${b.block}`) + .join('\n'); + + return { + content: [{ + type: 'text', + text: `Scanned ${blocks.length} non-air blocks:\n\nBlock types:\n${summary}\n\nPositions:\n${detail}`, + }], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true, + }; + } + } + ); + // ── MCP Resources (Phase 6) ───────────────────────────────────────── // Resource: Block ID reference @@ -904,6 +1232,15 @@ export function startMcpServer(bedrock, port = 3002) { // Build it const prepared = commands.map((line) => createCommandMessage(line)); + // Create build state for persistence + const buildId = buildState.createBuild({ + source: 'schematic', + sourceUrl: url, + name: summary.name, + originX, originY, originZ, + commands, + }); + const progressFn = (progress) => { try { sendNotification({ @@ -919,9 +1256,28 @@ export function startMcpServer(bedrock, port = 3002) { } }; - const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn); + const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn, { + buildId, + buildState, + }); + + if (result.paused) { + return { + content: [{ + type: 'text', + text: `Schematic "${summary.name}" build paused at ${result.completedIndex}/${commands.length} commands.\nBuild ID: ${buildId}\nUse minecraft_resume_build to continue.`, + }], + }; + } + + if (result.cancelled) { + buildState.cancelBuild(buildId, result.succeeded, result.failed); + } else { + buildState.completeBuild(buildId, result.succeeded, result.failed); + } let text = `Schematic "${summary.name}" build ${result.cancelled ? 'cancelled' : 'complete'}!\n`; + text += `Build ID: ${buildId}\n`; text += `Blocks placed: ${result.succeeded}/${result.total}\n`; text += `Failed: ${result.failed}\n`; text += `Dimensions: ${summary.dimensions.width}W x ${summary.dimensions.height}H x ${summary.dimensions.depth}D\n`; diff --git a/src/player-automation.js b/src/player-automation.js new file mode 100644 index 0000000..63e1e17 --- /dev/null +++ b/src/player-automation.js @@ -0,0 +1,135 @@ +import { log } from './utils.js'; + +const TAG = 'Automation'; + +/** + * Higher-level player automation via Bedrock commands. + * Phase 1: Command-based automation (no bot client needed). + */ +export class PlayerAutomation { + /** + * @param {import('./bedrock-ws.js').BedrockWebSocket} bedrock + */ + constructor(bedrock) { + this._bedrock = bedrock; + } + + /** + * Teleport the connected player to coordinates. + * @param {number} x + * @param {number} y + * @param {number} z + * @param {string} [player='@p'] + * @returns {Promise} + */ + async teleportTo(x, y, z, player = '@p') { + log(TAG, `Teleporting ${player} to ${x} ${y} ${z}`); + return this._bedrock.sendCommand(`tp ${player} ${x} ${y} ${z}`); + } + + /** + * Give items to a player. + * @param {string} item - Item ID (e.g. "diamond_sword") + * @param {number} [amount=1] + * @param {string} [player='@p'] + * @returns {Promise} + */ + async giveItem(item, amount = 1, player = '@p') { + log(TAG, `Giving ${amount}x ${item} to ${player}`); + return this._bedrock.sendCommand(`give ${player} ${item} ${amount}`); + } + + /** + * Clear area by filling with air. + * Splits into 32x32x32 chunks to respect Bedrock limits. + * @param {number} x1 + * @param {number} y1 + * @param {number} z1 + * @param {number} x2 + * @param {number} y2 + * @param {number} z2 + * @returns {Promise<{ total: number, succeeded: number, failed: number }>} + */ + async clearArea(x1, y1, z1, x2, y2, z2) { + const minX = Math.min(x1, x2), maxX = Math.max(x1, x2); + const minY = Math.min(y1, y2), maxY = Math.max(y1, y2); + const minZ = Math.min(z1, z2), maxZ = Math.max(z1, z2); + + const commands = []; + for (let x = minX; x <= maxX; x += 32) { + for (let y = minY; y <= maxY; y += 32) { + for (let z = minZ; z <= maxZ; z += 32) { + const ex = Math.min(x + 31, maxX); + const ey = Math.min(y + 31, maxY); + const ez = Math.min(z + 31, maxZ); + commands.push(`fill ${x} ${y} ${z} ${ex} ${ey} ${ez} air`); + } + } + } + + log(TAG, `Clearing area ${minX},${minY},${minZ} to ${maxX},${maxY},${maxZ} (${commands.length} fill commands)`); + + const { createCommandMessage } = await import('./utils.js'); + const prepared = commands.map(cmd => createCommandMessage(cmd)); + return this._bedrock.commandQueue.enqueueBatch(prepared); + } + + /** + * Scan blocks in a region using testforblock commands. + * Returns a map of positions to block types. + * @param {number} x1 + * @param {number} y1 + * @param {number} z1 + * @param {number} x2 + * @param {number} y2 + * @param {number} z2 + * @returns {Promise>} + */ + async scanBlocks(x1, y1, z1, x2, y2, z2) { + const minX = Math.min(x1, x2), maxX = Math.max(x1, x2); + const minY = Math.min(y1, y2), maxY = Math.max(y1, y2); + const minZ = Math.min(z1, z2), maxZ = Math.max(z1, z2); + + const volume = (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1); + if (volume > 1000) { + throw new Error(`Scan area too large: ${volume} blocks (max 1000). Use a smaller region.`); + } + + log(TAG, `Scanning ${volume} blocks from ${minX},${minY},${minZ} to ${maxX},${maxY},${maxZ}`); + + const results = []; + + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + for (let x = minX; x <= maxX; x++) { + try { + const response = await this._bedrock.sendCommand(`testforblock ${x} ${y} ${z} air`); + const msg = response?.statusMessage || ''; + + // If statusCode is 0, it's air. Otherwise parse the "expected X, got Y" message + if (response?.statusCode === 0) { + // Air block - skip for brevity + continue; + } + + // Extract block name from error message like "The block at ... is ... (expected: air)." + // or "Successfully found the block at ..." + let block = 'unknown'; + // Bedrock returns something like: "The block at 0, 64, 0 is stone." + const blockMatch = msg.match(/is\s+(\S+)/i); + if (blockMatch) { + block = blockMatch[1].replace(/[.\s]+$/, ''); + } + + results.push({ x, y, z, block }); + } catch { + // Skip blocks that error + } + } + } + } + + log(TAG, `Scan complete: ${results.length} non-air blocks found`); + return results; + } +}