feat: add fill optimizer, pause/resume builds, and player automation
All checks were successful
Deploy to Docker / deploy (push) Successful in 1m28s
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:
253
src/build-state.js
Normal file
253
src/build-state.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export class CommandQueue {
|
|||||||
this._totalSent = 0;
|
this._totalSent = 0;
|
||||||
this._totalCompleted = 0;
|
this._totalCompleted = 0;
|
||||||
this._cancelBuild = false;
|
this._cancelBuild = false;
|
||||||
|
this._pauseBuild = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,21 +111,43 @@ export class CommandQueue {
|
|||||||
/**
|
/**
|
||||||
* Enqueue a batch with progress reporting.
|
* Enqueue a batch with progress reporting.
|
||||||
* Calls progressFn with status updates between layer batches.
|
* 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 {Array<{id: string, message: string}>} commands
|
||||||
* @param {(progress: { completed: number, total: number, percent: number }) => void} [progressFn]
|
* @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._cancelBuild = false;
|
||||||
|
this._pauseBuild = false;
|
||||||
const results = [];
|
const results = [];
|
||||||
let succeeded = 0;
|
let succeeded = opts.startIndex ? 0 : 0; // Fresh count for this run
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
const startIndex = opts.startIndex || 0;
|
||||||
|
|
||||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||||
// Check cancellation
|
// Check cancellation
|
||||||
if (this._cancelBuild) {
|
if (this._cancelBuild) {
|
||||||
log(TAG, `Build cancelled at ${i}/${commands.length} commands`);
|
const completedIndex = startIndex + i;
|
||||||
return { total: commands.length, succeeded, failed, cancelled: true, results };
|
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);
|
const batch = commands.slice(i, i + this.batchSize);
|
||||||
@@ -145,9 +168,16 @@ export class CommandQueue {
|
|||||||
|
|
||||||
// Report progress
|
// Report progress
|
||||||
const completed = i + batch.length;
|
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) {
|
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)
|
// 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 */
|
/** Cancel an in-progress build */
|
||||||
@@ -164,6 +194,11 @@ export class CommandQueue {
|
|||||||
this._cancelBuild = true;
|
this._cancelBuild = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pause an in-progress build */
|
||||||
|
pauseBuild() {
|
||||||
|
this._pauseBuild = true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Process queued commands respecting rate limits */
|
/** Process queued commands respecting rate limits */
|
||||||
async _processQueue() {
|
async _processQueue() {
|
||||||
if (this._processing) return;
|
if (this._processing) return;
|
||||||
|
|||||||
232
src/fill-optimizer.js
Normal file
232
src/fill-optimizer.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { log, logError } from './utils.js';
|
import { log, logError } from './utils.js';
|
||||||
import { resolveBlock, formatBlock, getUnknownBlocks, clearUnknownBlocks } from './block-map.js';
|
import { resolveBlock, formatBlock, getUnknownBlocks, clearUnknownBlocks } from './block-map.js';
|
||||||
import { fetchPage as fetchPageBrowser } from './schematics-browser.js';
|
import { fetchPage as fetchPageBrowser } from './schematics-browser.js';
|
||||||
|
import { optimizeVoxels } from './fill-optimizer.js';
|
||||||
|
|
||||||
const TAG = 'GrabCraft';
|
const TAG = 'GrabCraft';
|
||||||
|
|
||||||
@@ -191,43 +192,24 @@ export function blueprintToCommands(blueprint, originX, originY, originZ) {
|
|||||||
clearUnknownBlocks();
|
clearUnknownBlocks();
|
||||||
|
|
||||||
const { voxels, origin } = blueprint;
|
const { voxels, origin } = blueprint;
|
||||||
const commands = [];
|
|
||||||
const materialCounts = new Map();
|
|
||||||
let skippedAir = 0;
|
|
||||||
|
|
||||||
// Sort voxels bottom-up (y ascending) for structural integrity
|
// Use fill optimizer for dramatic command reduction
|
||||||
const sorted = [...voxels].sort((a, b) => {
|
const { commands, materialCounts, skippedAir } = optimizeVoxels(
|
||||||
if (a.y !== b.y) return a.y - b.y;
|
voxels,
|
||||||
if (a.z !== b.z) return a.z - b.z;
|
originX,
|
||||||
return a.x - b.x;
|
originY,
|
||||||
});
|
originZ,
|
||||||
|
origin,
|
||||||
for (const voxel of sorted) {
|
resolveBlock,
|
||||||
// Skip air blocks
|
formatBlock
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unknowns = getUnknownBlocks();
|
const unknowns = getUnknownBlocks();
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
name: blueprint.name,
|
name: blueprint.name,
|
||||||
totalVoxels: voxels.length,
|
totalVoxels: voxels.length,
|
||||||
|
totalBlocks: voxels.length - skippedAir,
|
||||||
totalCommands: commands.length,
|
totalCommands: commands.length,
|
||||||
skippedAir,
|
skippedAir,
|
||||||
dimensions: blueprint.dimensions,
|
dimensions: blueprint.dimensions,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { searchBlueprints, fetchBlueprint, blueprintToCommands, getCategories }
|
|||||||
import { searchSchematics, fetchSchematic, getSchematicCategories } from './schematics.js';
|
import { searchSchematics, fetchSchematic, getSchematicCategories } from './schematics.js';
|
||||||
import { getAllBlocks } from './block-map.js';
|
import { getAllBlocks } from './block-map.js';
|
||||||
import { SHAPES } from './building-helpers.js';
|
import { SHAPES } from './building-helpers.js';
|
||||||
|
import { BuildStateManager } from './build-state.js';
|
||||||
|
import { PlayerAutomation } from './player-automation.js';
|
||||||
|
|
||||||
const TAG = 'MCP';
|
const TAG = 'MCP';
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
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
|
// Track active transports by session
|
||||||
const transports = {};
|
const transports = {};
|
||||||
|
|
||||||
@@ -566,6 +572,15 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
// Build it
|
// Build it
|
||||||
const prepared = commands.map((line) => createCommandMessage(line));
|
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) => {
|
const progressFn = (progress) => {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
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`;
|
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 += `Blocks placed: ${result.succeeded}/${result.total}\n`;
|
||||||
text += `Failed: ${result.failed}\n`;
|
text += `Failed: ${result.failed}\n`;
|
||||||
text += `Dimensions: ${summary.dimensions.width}W x ${summary.dimensions.height}H x ${summary.dimensions.depth}D\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',
|
'minecraft_cancel_build',
|
||||||
{
|
{
|
||||||
title: '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({}),
|
inputSchema: z.object({}),
|
||||||
},
|
},
|
||||||
async () => {
|
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) ─────────────────────────────────────────
|
// ── MCP Resources (Phase 6) ─────────────────────────────────────────
|
||||||
|
|
||||||
// Resource: Block ID reference
|
// Resource: Block ID reference
|
||||||
@@ -904,6 +1232,15 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
// Build it
|
// Build it
|
||||||
const prepared = commands.map((line) => createCommandMessage(line));
|
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) => {
|
const progressFn = (progress) => {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
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`;
|
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 += `Blocks placed: ${result.succeeded}/${result.total}\n`;
|
||||||
text += `Failed: ${result.failed}\n`;
|
text += `Failed: ${result.failed}\n`;
|
||||||
text += `Dimensions: ${summary.dimensions.width}W x ${summary.dimensions.height}H x ${summary.dimensions.depth}D\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
135
src/player-automation.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user