feat: add fill optimizer, pause/resume builds, and player automation
All checks were successful
Deploy to Docker / deploy (push) Successful in 1m28s

Fill optimizer reduces command count 5-15x by merging adjacent same-type
blocks into fill commands. Build state manager persists progress to disk
for pause/resume across disconnects. Player automation adds teleport,
give item, clear area, and scan area tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:52:47 +00:00
parent 91b8ce0624
commit 36a1806e13
6 changed files with 1034 additions and 41 deletions

253
src/build-state.js Normal file
View File

@@ -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:
* <buildId>.meta.json - small, updated per batch (status, progress)
* <buildId>.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
}
}
}

View File

@@ -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;

232
src/fill-optimizer.js Normal file
View File

@@ -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<number, Array<{ x: number, z: number, blockStr: string }>>} */
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<string, Array<{ x: number, z: number }>>} */
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<number, number[]>} 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<number, Array<{ x1: number, x2: number }>>} */
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<number, Array<{ x1: number, x2: number }>>} 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<string, number>, 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 };
}

View File

@@ -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,

View File

@@ -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`;

135
src/player-automation.js Normal file
View File

@@ -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<object>}
*/
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<object>}
*/
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<Array<{ x: number, y: number, z: number, block: string }>>}
*/
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;
}
}