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._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
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 { 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,
|
||||
|
||||
@@ -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
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