feat: add GrabCraft blueprints, building helpers, and world state awareness
All checks were successful
Deploy to Docker / deploy (push) Successful in 12s

- Phase 1: Player position (querytarget @s) and testforblock tools
- Phase 2: GrabCraft scraper with LRU cache, 372-block Java-to-Bedrock
  mapping, search and auto-build blueprint tools with dryRun support
- Phase 3: Raise build limit to 5000 (MAX_BUILD_COMMANDS env), add
  progress notifications and build cancellation
- Phase 4: Geometric shape builders (sphere, cylinder, dome, pyramid,
  wall, box) using fill commands for efficiency
- Phase 5: Event buffer 100->1000 (EVENT_BUFFER_SIZE env), add
  getByTypes and getSince query methods
- Phase 6: MCP resources for block ID reference and GrabCraft categories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 22:19:52 +00:00
parent 6ad340ff88
commit 6a22a5155b
16 changed files with 4843 additions and 2636 deletions

View File

@@ -1,13 +1,15 @@
import { WebSocketServer } from 'ws';
import { createSubscribeMessage, createCommandMessage, sanitize, log, logError } from './utils.js';
import { createSubscribeMessage, createCommandMessage, createEnableEncryptionMessage, sanitize, log, logError } from './utils.js';
import { EventStore } from './event-store.js';
import { CommandQueue } from './command-queue.js';
import { ServerEncryption } from './encryption.js';
const TAG = 'BedrockWS';
/**
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
* Only one Minecraft client is supported at a time.
* Supports Bedrock's application-level encryption handshake.
*/
export class BedrockWebSocket {
/**
@@ -16,7 +18,7 @@ export class BedrockWebSocket {
*/
constructor(opts = {}) {
this.port = opts.port ?? 3001;
this.events = new EventStore(100);
this.events = new EventStore();
this.commandQueue = new CommandQueue();
/** @type {import('ws').WebSocket | null} */
@@ -25,11 +27,26 @@ export class BedrockWebSocket {
this._connectedAt = null;
this._playerName = null;
this._subscriptions = new Set();
// Encryption state
/** @type {ServerEncryption | null} */
this._encryption = null;
this._pendingEncryption = false;
this._encryptionRequestId = null;
}
/** Start the WebSocket server */
start() {
this._wss = new WebSocketServer({ port: this.port });
this._wss = new WebSocketServer({
port: this.port,
handleProtocols: (protocols) => {
// Accept Bedrock's encryption subprotocol if offered
if (protocols.has('com.microsoft.minecraft.wsencrypt')) {
return 'com.microsoft.minecraft.wsencrypt';
}
return false;
},
});
this._wss.on('listening', () => {
log(TAG, `WebSocket server listening on port ${this.port}`);
@@ -48,21 +65,23 @@ export class BedrockWebSocket {
this._connectedAt = new Date();
log(TAG, 'Minecraft client connected!');
// Wire up command queue to send over this socket
this.commandQueue.setSendFunction((id, message) => {
if (this._ws && this._ws.readyState === 1) {
this._ws.send(message);
} else {
throw new Error('WebSocket not connected');
}
});
// Auto-subscribe to key events
this._autoSubscribe();
// Start encryption handshake BEFORE wiring up command queue
this._beginEncryptionHandshake();
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw.toString());
let data;
if (this._encryption && this._encryption.enabled) {
// All messages after handshake are encrypted
const buf = typeof raw === 'string' ? Buffer.from(raw) : raw;
const plaintext = this._encryption.decrypt(buf);
data = JSON.parse(plaintext);
} else {
// Pre-encryption: plaintext JSON
data = JSON.parse(raw.toString());
}
this._handleMessage(data);
} catch (err) {
logError(TAG, 'Failed to parse message:', err.message);
@@ -75,6 +94,9 @@ export class BedrockWebSocket {
this._connectedAt = null;
this._playerName = null;
this._subscriptions.clear();
this._encryption = null;
this._pendingEncryption = false;
this._encryptionRequestId = null;
this.commandQueue.setSendFunction(null);
});
@@ -88,6 +110,44 @@ export class BedrockWebSocket {
});
}
/** Initiate the Bedrock encryption handshake */
_beginEncryptionHandshake() {
this._encryption = new ServerEncryption();
this._pendingEncryption = true;
const { publicKey, salt } = this._encryption.getKeyExchangeParams();
const { id, message } = createEnableEncryptionMessage(publicKey, salt);
this._encryptionRequestId = id;
log(TAG, 'Sending enableencryption handshake...');
// Send plaintext — this is the last unencrypted message from server
if (this._ws && this._ws.readyState === 1) {
this._ws.send(message);
}
}
/** Called after encryption handshake completes to wire up normal operation */
_onEncryptionReady() {
log(TAG, 'Encryption active — wiring command queue and auto-subscribing');
// Now wire up command queue to send through encryption
this.commandQueue.setSendFunction((id, message) => {
if (this._ws && this._ws.readyState === 1) {
if (this._encryption && this._encryption.enabled) {
this._ws.send(this._encryption.encrypt(message));
} else {
this._ws.send(message);
}
} else {
throw new Error('WebSocket not connected');
}
});
// Auto-subscribe to key events
this._autoSubscribe();
}
/** Subscribe to default event types */
_autoSubscribe() {
const defaultEvents = ['PlayerMessage'];
@@ -111,7 +171,12 @@ export class BedrockWebSocket {
return true;
}
this._ws.send(createSubscribeMessage(eventName));
const msg = createSubscribeMessage(eventName);
if (this._encryption && this._encryption.enabled) {
this._ws.send(this._encryption.encrypt(msg));
} else {
this._ws.send(msg);
}
this._subscriptions.add(eventName);
log(TAG, `Subscribed to ${eventName}`);
return true;
@@ -124,6 +189,33 @@ export class BedrockWebSocket {
_handleMessage(data) {
const purpose = data?.header?.messagePurpose;
// Intercept encryption handshake response
if (this._pendingEncryption && purpose === 'commandResponse') {
const requestId = data.header.requestId;
if (requestId === this._encryptionRequestId) {
this._pendingEncryption = false;
this._encryptionRequestId = null;
const publicKey = data.body?.publicKey;
if (publicKey) {
try {
this._encryption.completeKeyExchange(publicKey);
this._onEncryptionReady();
} catch (err) {
logError(TAG, 'Encryption key exchange failed:', err.message);
// Fall back to unencrypted mode
this._encryption = null;
this._onEncryptionReady();
}
} else {
logError(TAG, 'No public key in encryption response — falling back to plaintext');
this._encryption = null;
this._onEncryptionReady();
}
return;
}
}
if (purpose === 'commandResponse') {
// Response to a command we sent
const requestId = data.header.requestId;
@@ -198,10 +290,76 @@ export class BedrockWebSocket {
return this._ws !== null && this._ws.readyState === 1;
}
/**
* Get the player's current position using /querytarget @s.
* @returns {Promise<{ x: number, y: number, z: number, rx: number, ry: number, dimension: number }>}
*/
async getPlayerPosition() {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const response = await this.sendCommand('querytarget @s');
const details = response?.details;
if (!details) {
throw new Error('No response from querytarget — is a player connected?');
}
// querytarget returns a JSON string in details field
try {
let parsed;
if (typeof details === 'string') {
// Response is a JSON array string like: [{"uniqueId":...,"position":{...},...}]
parsed = JSON.parse(details);
} else {
parsed = details;
}
const target = Array.isArray(parsed) ? parsed[0] : parsed;
if (!target || !target.position) {
throw new Error('Invalid querytarget response format');
}
return {
x: Math.floor(target.position.x),
y: Math.floor(target.position.y),
z: Math.floor(target.position.z),
rx: target.yRot ?? 0,
ry: target.xRot ?? 0,
dimension: target.dimension ?? 0,
};
} catch (err) {
if (err.message.includes('Invalid querytarget')) throw err;
throw new Error(`Failed to parse position data: ${err.message}`);
}
}
/**
* Test for a specific block at coordinates.
* @param {number} x
* @param {number} y
* @param {number} z
* @param {string} [blockId] - Optional block ID to test for
* @returns {Promise<object>} Test result
*/
async testForBlock(x, y, z, blockId) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const cmd = blockId
? `testforblock ${x} ${y} ${z} ${blockId}`
: `testforblock ${x} ${y} ${z}`;
return this.sendCommand(cmd);
}
/** @returns {object} Status information */
getStatus() {
return {
connected: this.isConnected(),
encrypted: this._encryption?.enabled ?? false,
playerName: this._playerName,
connectedAt: this._connectedAt?.toISOString() || null,
subscriptions: [...this._subscriptions],

692
src/block-map.js Normal file
View File

@@ -0,0 +1,692 @@
import { log } from './utils.js';
const TAG = 'BlockMap';
/**
* Mapping from GrabCraft Java Edition block IDs to Bedrock Edition block IDs.
* GrabCraft uses format like "5:5" (blockId:dataValue) and English names.
* Bedrock uses string IDs like "planks" with data values.
*
* Format: { javaId: "numericId:data", bedrockId: "block_name", bedrockData: number }
*/
const BLOCK_MAP = new Map([
// ── Air ──
['0', { bedrock: 'air', data: 0, name: 'Air' }],
// ── Stone variants ──
['1', { bedrock: 'stone', data: 0, name: 'Stone' }],
['1:1', { bedrock: 'stone', data: 1, name: 'Granite' }],
['1:2', { bedrock: 'stone', data: 2, name: 'Polished Granite' }],
['1:3', { bedrock: 'stone', data: 3, name: 'Diorite' }],
['1:4', { bedrock: 'stone', data: 4, name: 'Polished Diorite' }],
['1:5', { bedrock: 'stone', data: 5, name: 'Andesite' }],
['1:6', { bedrock: 'stone', data: 6, name: 'Polished Andesite' }],
// ── Grass & Dirt ──
['2', { bedrock: 'grass_block', data: 0, name: 'Grass Block' }],
['3', { bedrock: 'dirt', data: 0, name: 'Dirt' }],
['3:1', { bedrock: 'dirt', data: 1, name: 'Coarse Dirt' }],
['3:2', { bedrock: 'podzol', data: 0, name: 'Podzol' }],
// ── Cobblestone ──
['4', { bedrock: 'cobblestone', data: 0, name: 'Cobblestone' }],
// ── Planks ──
['5', { bedrock: 'planks', data: 0, name: 'Oak Planks' }],
['5:1', { bedrock: 'planks', data: 1, name: 'Spruce Planks' }],
['5:2', { bedrock: 'planks', data: 2, name: 'Birch Planks' }],
['5:3', { bedrock: 'planks', data: 3, name: 'Jungle Planks' }],
['5:4', { bedrock: 'planks', data: 4, name: 'Acacia Planks' }],
['5:5', { bedrock: 'planks', data: 5, name: 'Dark Oak Planks' }],
// ── Saplings ──
['6', { bedrock: 'sapling', data: 0, name: 'Oak Sapling' }],
['6:1', { bedrock: 'sapling', data: 1, name: 'Spruce Sapling' }],
['6:2', { bedrock: 'sapling', data: 2, name: 'Birch Sapling' }],
['6:3', { bedrock: 'sapling', data: 3, name: 'Jungle Sapling' }],
['6:4', { bedrock: 'sapling', data: 4, name: 'Acacia Sapling' }],
['6:5', { bedrock: 'sapling', data: 5, name: 'Dark Oak Sapling' }],
// ── Bedrock ──
['7', { bedrock: 'bedrock', data: 0, name: 'Bedrock' }],
// ── Water & Lava ──
['8', { bedrock: 'water', data: 0, name: 'Water' }],
['9', { bedrock: 'water', data: 0, name: 'Water (stationary)' }],
['10', { bedrock: 'lava', data: 0, name: 'Lava' }],
['11', { bedrock: 'lava', data: 0, name: 'Lava (stationary)' }],
// ── Sand & Gravel ──
['12', { bedrock: 'sand', data: 0, name: 'Sand' }],
['12:1', { bedrock: 'sand', data: 1, name: 'Red Sand' }],
['13', { bedrock: 'gravel', data: 0, name: 'Gravel' }],
// ── Ores ──
['14', { bedrock: 'gold_ore', data: 0, name: 'Gold Ore' }],
['15', { bedrock: 'iron_ore', data: 0, name: 'Iron Ore' }],
['16', { bedrock: 'coal_ore', data: 0, name: 'Coal Ore' }],
// ── Logs ──
['17', { bedrock: 'log', data: 0, name: 'Oak Log' }],
['17:1', { bedrock: 'log', data: 1, name: 'Spruce Log' }],
['17:2', { bedrock: 'log', data: 2, name: 'Birch Log' }],
['17:3', { bedrock: 'log', data: 3, name: 'Jungle Log' }],
['162', { bedrock: 'log2', data: 0, name: 'Acacia Log' }],
['162:1', { bedrock: 'log2', data: 1, name: 'Dark Oak Log' }],
// ── Leaves ──
['18', { bedrock: 'leaves', data: 0, name: 'Oak Leaves' }],
['18:1', { bedrock: 'leaves', data: 1, name: 'Spruce Leaves' }],
['18:2', { bedrock: 'leaves', data: 2, name: 'Birch Leaves' }],
['18:3', { bedrock: 'leaves', data: 3, name: 'Jungle Leaves' }],
['161', { bedrock: 'leaves2', data: 0, name: 'Acacia Leaves' }],
['161:1', { bedrock: 'leaves2', data: 1, name: 'Dark Oak Leaves' }],
// ── Sponge ──
['19', { bedrock: 'sponge', data: 0, name: 'Sponge' }],
['19:1', { bedrock: 'sponge', data: 1, name: 'Wet Sponge' }],
// ── Glass ──
['20', { bedrock: 'glass', data: 0, name: 'Glass' }],
['102', { bedrock: 'glass_pane', data: 0, name: 'Glass Pane' }],
// ── Lapis ──
['21', { bedrock: 'lapis_ore', data: 0, name: 'Lapis Lazuli Ore' }],
['22', { bedrock: 'lapis_block', data: 0, name: 'Lapis Lazuli Block' }],
// ── Dispenser & Noteblock ──
['23', { bedrock: 'dispenser', data: 0, name: 'Dispenser' }],
['25', { bedrock: 'noteblock', data: 0, name: 'Note Block' }],
// ── Sandstone ──
['24', { bedrock: 'sandstone', data: 0, name: 'Sandstone' }],
['24:1', { bedrock: 'sandstone', data: 1, name: 'Chiseled Sandstone' }],
['24:2', { bedrock: 'sandstone', data: 2, name: 'Smooth Sandstone' }],
// ── Wool ──
['35', { bedrock: 'wool', data: 0, name: 'White Wool' }],
['35:1', { bedrock: 'wool', data: 1, name: 'Orange Wool' }],
['35:2', { bedrock: 'wool', data: 2, name: 'Magenta Wool' }],
['35:3', { bedrock: 'wool', data: 3, name: 'Light Blue Wool' }],
['35:4', { bedrock: 'wool', data: 4, name: 'Yellow Wool' }],
['35:5', { bedrock: 'wool', data: 5, name: 'Lime Wool' }],
['35:6', { bedrock: 'wool', data: 6, name: 'Pink Wool' }],
['35:7', { bedrock: 'wool', data: 7, name: 'Gray Wool' }],
['35:8', { bedrock: 'wool', data: 8, name: 'Light Gray Wool' }],
['35:9', { bedrock: 'wool', data: 9, name: 'Cyan Wool' }],
['35:10', { bedrock: 'wool', data: 10, name: 'Purple Wool' }],
['35:11', { bedrock: 'wool', data: 11, name: 'Blue Wool' }],
['35:12', { bedrock: 'wool', data: 12, name: 'Brown Wool' }],
['35:13', { bedrock: 'wool', data: 13, name: 'Green Wool' }],
['35:14', { bedrock: 'wool', data: 14, name: 'Red Wool' }],
['35:15', { bedrock: 'wool', data: 15, name: 'Black Wool' }],
// ── Gold & Iron blocks ──
['41', { bedrock: 'gold_block', data: 0, name: 'Block of Gold' }],
['42', { bedrock: 'iron_block', data: 0, name: 'Block of Iron' }],
// ── Slabs ──
['44', { bedrock: 'stone_block_slab', data: 0, name: 'Stone Slab' }],
['44:1', { bedrock: 'stone_block_slab', data: 1, name: 'Sandstone Slab' }],
['44:3', { bedrock: 'stone_block_slab', data: 3, name: 'Cobblestone Slab' }],
['44:4', { bedrock: 'stone_block_slab', data: 4, name: 'Brick Slab' }],
['44:5', { bedrock: 'stone_block_slab', data: 5, name: 'Stone Brick Slab' }],
['126', { bedrock: 'wooden_slab', data: 0, name: 'Oak Slab' }],
['126:1', { bedrock: 'wooden_slab', data: 1, name: 'Spruce Slab' }],
['126:2', { bedrock: 'wooden_slab', data: 2, name: 'Birch Slab' }],
['126:3', { bedrock: 'wooden_slab', data: 3, name: 'Jungle Slab' }],
['126:4', { bedrock: 'wooden_slab', data: 4, name: 'Acacia Slab' }],
['126:5', { bedrock: 'wooden_slab', data: 5, name: 'Dark Oak Slab' }],
// ── Bricks ──
['45', { bedrock: 'brick_block', data: 0, name: 'Bricks' }],
['98', { bedrock: 'stonebrick', data: 0, name: 'Stone Bricks' }],
['98:1', { bedrock: 'stonebrick', data: 1, name: 'Mossy Stone Bricks' }],
['98:2', { bedrock: 'stonebrick', data: 2, name: 'Cracked Stone Bricks' }],
['98:3', { bedrock: 'stonebrick', data: 3, name: 'Chiseled Stone Bricks' }],
['112', { bedrock: 'nether_brick', data: 0, name: 'Nether Bricks' }],
// ── TNT ──
['46', { bedrock: 'tnt', data: 0, name: 'TNT' }],
// ── Bookshelf ──
['47', { bedrock: 'bookshelf', data: 0, name: 'Bookshelf' }],
// ── Mossy Cobblestone ──
['48', { bedrock: 'mossy_cobblestone', data: 0, name: 'Mossy Cobblestone' }],
// ── Obsidian ──
['49', { bedrock: 'obsidian', data: 0, name: 'Obsidian' }],
// ── Torches ──
['50', { bedrock: 'torch', data: 0, name: 'Torch' }],
// ── Stairs ──
['53', { bedrock: 'oak_stairs', data: 0, name: 'Oak Stairs' }],
['67', { bedrock: 'stone_stairs', data: 0, name: 'Cobblestone Stairs' }],
['108', { bedrock: 'brick_stairs', data: 0, name: 'Brick Stairs' }],
['109', { bedrock: 'stone_brick_stairs', data: 0, name: 'Stone Brick Stairs' }],
['114', { bedrock: 'nether_brick_stairs', data: 0, name: 'Nether Brick Stairs' }],
['128', { bedrock: 'sandstone_stairs', data: 0, name: 'Sandstone Stairs' }],
['134', { bedrock: 'spruce_stairs', data: 0, name: 'Spruce Stairs' }],
['135', { bedrock: 'birch_stairs', data: 0, name: 'Birch Stairs' }],
['136', { bedrock: 'jungle_stairs', data: 0, name: 'Jungle Stairs' }],
['163', { bedrock: 'acacia_stairs', data: 0, name: 'Acacia Stairs' }],
['164', { bedrock: 'dark_oak_stairs', data: 0, name: 'Dark Oak Stairs' }],
['156', { bedrock: 'quartz_stairs', data: 0, name: 'Quartz Stairs' }],
// ── Chest ──
['54', { bedrock: 'chest', data: 0, name: 'Chest' }],
// ── Diamond ──
['56', { bedrock: 'diamond_ore', data: 0, name: 'Diamond Ore' }],
['57', { bedrock: 'diamond_block', data: 0, name: 'Block of Diamond' }],
// ── Crafting Table ──
['58', { bedrock: 'crafting_table', data: 0, name: 'Crafting Table' }],
// ── Furnace ──
['61', { bedrock: 'furnace', data: 0, name: 'Furnace' }],
['62', { bedrock: 'lit_furnace', data: 0, name: 'Burning Furnace' }],
// ── Doors ──
['64', { bedrock: 'wooden_door', data: 0, name: 'Oak Door' }],
['71', { bedrock: 'iron_door', data: 0, name: 'Iron Door' }],
['193', { bedrock: 'spruce_door', data: 0, name: 'Spruce Door' }],
['194', { bedrock: 'birch_door', data: 0, name: 'Birch Door' }],
['195', { bedrock: 'jungle_door', data: 0, name: 'Jungle Door' }],
['196', { bedrock: 'acacia_door', data: 0, name: 'Acacia Door' }],
['197', { bedrock: 'dark_oak_door', data: 0, name: 'Dark Oak Door' }],
// ── Ladders ──
['65', { bedrock: 'ladder', data: 0, name: 'Ladder' }],
// ── Rails ──
['66', { bedrock: 'rail', data: 0, name: 'Rail' }],
['27', { bedrock: 'golden_rail', data: 0, name: 'Powered Rail' }],
['28', { bedrock: 'detector_rail', data: 0, name: 'Detector Rail' }],
['157', { bedrock: 'activator_rail', data: 0, name: 'Activator Rail' }],
// ── Snow & Ice ──
['78', { bedrock: 'snow_layer', data: 0, name: 'Snow Layer' }],
['79', { bedrock: 'ice', data: 0, name: 'Ice' }],
['80', { bedrock: 'snow', data: 0, name: 'Snow Block' }],
['174', { bedrock: 'packed_ice', data: 0, name: 'Packed Ice' }],
// ── Cactus ──
['81', { bedrock: 'cactus', data: 0, name: 'Cactus' }],
// ── Clay ──
['82', { bedrock: 'clay', data: 0, name: 'Clay' }],
// ── Jukebox ──
['84', { bedrock: 'jukebox', data: 0, name: 'Jukebox' }],
// ── Fences ──
['85', { bedrock: 'fence', data: 0, name: 'Oak Fence' }],
['113', { bedrock: 'nether_brick_fence', data: 0, name: 'Nether Brick Fence' }],
['188', { bedrock: 'fence', data: 1, name: 'Spruce Fence' }],
['189', { bedrock: 'fence', data: 2, name: 'Birch Fence' }],
['190', { bedrock: 'fence', data: 3, name: 'Jungle Fence' }],
['191', { bedrock: 'fence', data: 4, name: 'Acacia Fence' }],
['192', { bedrock: 'fence', data: 5, name: 'Dark Oak Fence' }],
// ── Fence Gates ──
['107', { bedrock: 'fence_gate', data: 0, name: 'Oak Fence Gate' }],
['183', { bedrock: 'spruce_fence_gate', data: 0, name: 'Spruce Fence Gate' }],
['184', { bedrock: 'birch_fence_gate', data: 0, name: 'Birch Fence Gate' }],
['185', { bedrock: 'jungle_fence_gate', data: 0, name: 'Jungle Fence Gate' }],
['186', { bedrock: 'acacia_fence_gate', data: 0, name: 'Acacia Fence Gate' }],
['187', { bedrock: 'dark_oak_fence_gate', data: 0, name: 'Dark Oak Fence Gate' }],
// ── Pumpkin & Melon ──
['86', { bedrock: 'pumpkin', data: 0, name: 'Pumpkin' }],
['91', { bedrock: 'lit_pumpkin', data: 0, name: 'Jack o\'Lantern' }],
['103', { bedrock: 'melon_block', data: 0, name: 'Melon Block' }],
// ── Netherrack & Soul Sand ──
['87', { bedrock: 'netherrack', data: 0, name: 'Netherrack' }],
['88', { bedrock: 'soul_sand', data: 0, name: 'Soul Sand' }],
// ── Glowstone ──
['89', { bedrock: 'glowstone', data: 0, name: 'Glowstone' }],
// ── Stained Glass ──
['95', { bedrock: 'stained_glass', data: 0, name: 'White Stained Glass' }],
['95:1', { bedrock: 'stained_glass', data: 1, name: 'Orange Stained Glass' }],
['95:2', { bedrock: 'stained_glass', data: 2, name: 'Magenta Stained Glass' }],
['95:3', { bedrock: 'stained_glass', data: 3, name: 'Light Blue Stained Glass' }],
['95:4', { bedrock: 'stained_glass', data: 4, name: 'Yellow Stained Glass' }],
['95:5', { bedrock: 'stained_glass', data: 5, name: 'Lime Stained Glass' }],
['95:6', { bedrock: 'stained_glass', data: 6, name: 'Pink Stained Glass' }],
['95:7', { bedrock: 'stained_glass', data: 7, name: 'Gray Stained Glass' }],
['95:8', { bedrock: 'stained_glass', data: 8, name: 'Light Gray Stained Glass' }],
['95:9', { bedrock: 'stained_glass', data: 9, name: 'Cyan Stained Glass' }],
['95:10', { bedrock: 'stained_glass', data: 10, name: 'Purple Stained Glass' }],
['95:11', { bedrock: 'stained_glass', data: 11, name: 'Blue Stained Glass' }],
['95:12', { bedrock: 'stained_glass', data: 12, name: 'Brown Stained Glass' }],
['95:13', { bedrock: 'stained_glass', data: 13, name: 'Green Stained Glass' }],
['95:14', { bedrock: 'stained_glass', data: 14, name: 'Red Stained Glass' }],
['95:15', { bedrock: 'stained_glass', data: 15, name: 'Black Stained Glass' }],
// ── Stained Glass Panes ──
['160', { bedrock: 'stained_glass_pane', data: 0, name: 'White Stained Glass Pane' }],
['160:1', { bedrock: 'stained_glass_pane', data: 1, name: 'Orange Stained Glass Pane' }],
['160:2', { bedrock: 'stained_glass_pane', data: 2, name: 'Magenta Stained Glass Pane' }],
['160:3', { bedrock: 'stained_glass_pane', data: 3, name: 'Light Blue Stained Glass Pane' }],
['160:4', { bedrock: 'stained_glass_pane', data: 4, name: 'Yellow Stained Glass Pane' }],
['160:5', { bedrock: 'stained_glass_pane', data: 5, name: 'Lime Stained Glass Pane' }],
['160:6', { bedrock: 'stained_glass_pane', data: 6, name: 'Pink Stained Glass Pane' }],
['160:7', { bedrock: 'stained_glass_pane', data: 7, name: 'Gray Stained Glass Pane' }],
['160:8', { bedrock: 'stained_glass_pane', data: 8, name: 'Light Gray Stained Glass Pane' }],
['160:9', { bedrock: 'stained_glass_pane', data: 9, name: 'Cyan Stained Glass Pane' }],
['160:10', { bedrock: 'stained_glass_pane', data: 10, name: 'Purple Stained Glass Pane' }],
['160:11', { bedrock: 'stained_glass_pane', data: 11, name: 'Blue Stained Glass Pane' }],
['160:12', { bedrock: 'stained_glass_pane', data: 12, name: 'Brown Stained Glass Pane' }],
['160:13', { bedrock: 'stained_glass_pane', data: 13, name: 'Green Stained Glass Pane' }],
['160:14', { bedrock: 'stained_glass_pane', data: 14, name: 'Red Stained Glass Pane' }],
['160:15', { bedrock: 'stained_glass_pane', data: 15, name: 'Black Stained Glass Pane' }],
// ── Iron Bars ──
['101', { bedrock: 'iron_bars', data: 0, name: 'Iron Bars' }],
// ── Quartz ──
['155', { bedrock: 'quartz_block', data: 0, name: 'Quartz Block' }],
['155:1', { bedrock: 'quartz_block', data: 1, name: 'Chiseled Quartz' }],
['155:2', { bedrock: 'quartz_block', data: 2, name: 'Pillar Quartz' }],
// ── Terracotta (Hardened Clay) ──
['159', { bedrock: 'stained_hardened_clay', data: 0, name: 'White Terracotta' }],
['159:1', { bedrock: 'stained_hardened_clay', data: 1, name: 'Orange Terracotta' }],
['159:2', { bedrock: 'stained_hardened_clay', data: 2, name: 'Magenta Terracotta' }],
['159:3', { bedrock: 'stained_hardened_clay', data: 3, name: 'Light Blue Terracotta' }],
['159:4', { bedrock: 'stained_hardened_clay', data: 4, name: 'Yellow Terracotta' }],
['159:5', { bedrock: 'stained_hardened_clay', data: 5, name: 'Lime Terracotta' }],
['159:6', { bedrock: 'stained_hardened_clay', data: 6, name: 'Pink Terracotta' }],
['159:7', { bedrock: 'stained_hardened_clay', data: 7, name: 'Gray Terracotta' }],
['159:8', { bedrock: 'stained_hardened_clay', data: 8, name: 'Light Gray Terracotta' }],
['159:9', { bedrock: 'stained_hardened_clay', data: 9, name: 'Cyan Terracotta' }],
['159:10', { bedrock: 'stained_hardened_clay', data: 10, name: 'Purple Terracotta' }],
['159:11', { bedrock: 'stained_hardened_clay', data: 11, name: 'Blue Terracotta' }],
['159:12', { bedrock: 'stained_hardened_clay', data: 12, name: 'Brown Terracotta' }],
['159:13', { bedrock: 'stained_hardened_clay', data: 13, name: 'Green Terracotta' }],
['159:14', { bedrock: 'stained_hardened_clay', data: 14, name: 'Red Terracotta' }],
['159:15', { bedrock: 'stained_hardened_clay', data: 15, name: 'Black Terracotta' }],
['172', { bedrock: 'hardened_clay', data: 0, name: 'Terracotta' }],
// ── Concrete ──
['251', { bedrock: 'concrete', data: 0, name: 'White Concrete' }],
['251:1', { bedrock: 'concrete', data: 1, name: 'Orange Concrete' }],
['251:2', { bedrock: 'concrete', data: 2, name: 'Magenta Concrete' }],
['251:3', { bedrock: 'concrete', data: 3, name: 'Light Blue Concrete' }],
['251:4', { bedrock: 'concrete', data: 4, name: 'Yellow Concrete' }],
['251:5', { bedrock: 'concrete', data: 5, name: 'Lime Concrete' }],
['251:6', { bedrock: 'concrete', data: 6, name: 'Pink Concrete' }],
['251:7', { bedrock: 'concrete', data: 7, name: 'Gray Concrete' }],
['251:8', { bedrock: 'concrete', data: 8, name: 'Light Gray Concrete' }],
['251:9', { bedrock: 'concrete', data: 9, name: 'Cyan Concrete' }],
['251:10', { bedrock: 'concrete', data: 10, name: 'Purple Concrete' }],
['251:11', { bedrock: 'concrete', data: 11, name: 'Blue Concrete' }],
['251:12', { bedrock: 'concrete', data: 12, name: 'Brown Concrete' }],
['251:13', { bedrock: 'concrete', data: 13, name: 'Green Concrete' }],
['251:14', { bedrock: 'concrete', data: 14, name: 'Red Concrete' }],
['251:15', { bedrock: 'concrete', data: 15, name: 'Black Concrete' }],
// ── Concrete Powder ──
['252', { bedrock: 'concrete_powder', data: 0, name: 'White Concrete Powder' }],
['252:1', { bedrock: 'concrete_powder', data: 1, name: 'Orange Concrete Powder' }],
['252:2', { bedrock: 'concrete_powder', data: 2, name: 'Magenta Concrete Powder' }],
['252:3', { bedrock: 'concrete_powder', data: 3, name: 'Light Blue Concrete Powder' }],
['252:4', { bedrock: 'concrete_powder', data: 4, name: 'Yellow Concrete Powder' }],
['252:5', { bedrock: 'concrete_powder', data: 5, name: 'Lime Concrete Powder' }],
['252:6', { bedrock: 'concrete_powder', data: 6, name: 'Pink Concrete Powder' }],
['252:7', { bedrock: 'concrete_powder', data: 7, name: 'Gray Concrete Powder' }],
['252:8', { bedrock: 'concrete_powder', data: 8, name: 'Light Gray Concrete Powder' }],
['252:9', { bedrock: 'concrete_powder', data: 9, name: 'Cyan Concrete Powder' }],
['252:10', { bedrock: 'concrete_powder', data: 10, name: 'Purple Concrete Powder' }],
['252:11', { bedrock: 'concrete_powder', data: 11, name: 'Blue Concrete Powder' }],
['252:12', { bedrock: 'concrete_powder', data: 12, name: 'Brown Concrete Powder' }],
['252:13', { bedrock: 'concrete_powder', data: 13, name: 'Green Concrete Powder' }],
['252:14', { bedrock: 'concrete_powder', data: 14, name: 'Red Concrete Powder' }],
['252:15', { bedrock: 'concrete_powder', data: 15, name: 'Black Concrete Powder' }],
// ── Glazed Terracotta ──
['235', { bedrock: 'white_glazed_terracotta', data: 0, name: 'White Glazed Terracotta' }],
['236', { bedrock: 'orange_glazed_terracotta', data: 0, name: 'Orange Glazed Terracotta' }],
['237', { bedrock: 'magenta_glazed_terracotta', data: 0, name: 'Magenta Glazed Terracotta' }],
['238', { bedrock: 'light_blue_glazed_terracotta', data: 0, name: 'Light Blue Glazed Terracotta' }],
['239', { bedrock: 'yellow_glazed_terracotta', data: 0, name: 'Yellow Glazed Terracotta' }],
['240', { bedrock: 'lime_glazed_terracotta', data: 0, name: 'Lime Glazed Terracotta' }],
['241', { bedrock: 'pink_glazed_terracotta', data: 0, name: 'Pink Glazed Terracotta' }],
['242', { bedrock: 'gray_glazed_terracotta', data: 0, name: 'Gray Glazed Terracotta' }],
['243', { bedrock: 'silver_glazed_terracotta', data: 0, name: 'Light Gray Glazed Terracotta' }],
['244', { bedrock: 'cyan_glazed_terracotta', data: 0, name: 'Cyan Glazed Terracotta' }],
['245', { bedrock: 'purple_glazed_terracotta', data: 0, name: 'Purple Glazed Terracotta' }],
['246', { bedrock: 'blue_glazed_terracotta', data: 0, name: 'Blue Glazed Terracotta' }],
['247', { bedrock: 'brown_glazed_terracotta', data: 0, name: 'Brown Glazed Terracotta' }],
['248', { bedrock: 'green_glazed_terracotta', data: 0, name: 'Green Glazed Terracotta' }],
['249', { bedrock: 'red_glazed_terracotta', data: 0, name: 'Red Glazed Terracotta' }],
['250', { bedrock: 'black_glazed_terracotta', data: 0, name: 'Black Glazed Terracotta' }],
// ── Carpet ──
['171', { bedrock: 'carpet', data: 0, name: 'White Carpet' }],
['171:1', { bedrock: 'carpet', data: 1, name: 'Orange Carpet' }],
['171:2', { bedrock: 'carpet', data: 2, name: 'Magenta Carpet' }],
['171:3', { bedrock: 'carpet', data: 3, name: 'Light Blue Carpet' }],
['171:4', { bedrock: 'carpet', data: 4, name: 'Yellow Carpet' }],
['171:5', { bedrock: 'carpet', data: 5, name: 'Lime Carpet' }],
['171:6', { bedrock: 'carpet', data: 6, name: 'Pink Carpet' }],
['171:7', { bedrock: 'carpet', data: 7, name: 'Gray Carpet' }],
['171:8', { bedrock: 'carpet', data: 8, name: 'Light Gray Carpet' }],
['171:9', { bedrock: 'carpet', data: 9, name: 'Cyan Carpet' }],
['171:10', { bedrock: 'carpet', data: 10, name: 'Purple Carpet' }],
['171:11', { bedrock: 'carpet', data: 11, name: 'Blue Carpet' }],
['171:12', { bedrock: 'carpet', data: 12, name: 'Brown Carpet' }],
['171:13', { bedrock: 'carpet', data: 13, name: 'Green Carpet' }],
['171:14', { bedrock: 'carpet', data: 14, name: 'Red Carpet' }],
['171:15', { bedrock: 'carpet', data: 15, name: 'Black Carpet' }],
// ── Redstone ──
['55', { bedrock: 'redstone_wire', data: 0, name: 'Redstone Wire' }],
['73', { bedrock: 'redstone_ore', data: 0, name: 'Redstone Ore' }],
['76', { bedrock: 'redstone_torch', data: 0, name: 'Redstone Torch' }],
['69', { bedrock: 'lever', data: 0, name: 'Lever' }],
['70', { bedrock: 'stone_pressure_plate', data: 0, name: 'Stone Pressure Plate' }],
['72', { bedrock: 'wooden_pressure_plate', data: 0, name: 'Oak Pressure Plate' }],
['77', { bedrock: 'stone_button', data: 0, name: 'Stone Button' }],
['143', { bedrock: 'wooden_button', data: 0, name: 'Oak Button' }],
['123', { bedrock: 'redstone_lamp', data: 0, name: 'Redstone Lamp' }],
['33', { bedrock: 'piston', data: 0, name: 'Piston' }],
['29', { bedrock: 'sticky_piston', data: 0, name: 'Sticky Piston' }],
['93', { bedrock: 'unpowered_repeater', data: 0, name: 'Repeater' }],
['149', { bedrock: 'unpowered_comparator', data: 0, name: 'Comparator' }],
['152', { bedrock: 'redstone_block', data: 0, name: 'Block of Redstone' }],
['151', { bedrock: 'daylight_detector', data: 0, name: 'Daylight Detector' }],
['154', { bedrock: 'hopper', data: 0, name: 'Hopper' }],
['158', { bedrock: 'dropper', data: 0, name: 'Dropper' }],
['146', { bedrock: 'trapped_chest', data: 0, name: 'Trapped Chest' }],
['147', { bedrock: 'light_weighted_pressure_plate', data: 0, name: 'Light Weighted Pressure Plate' }],
['148', { bedrock: 'heavy_weighted_pressure_plate', data: 0, name: 'Heavy Weighted Pressure Plate' }],
// ── Trapdoors ──
['96', { bedrock: 'trapdoor', data: 0, name: 'Oak Trapdoor' }],
['167', { bedrock: 'iron_trapdoor', data: 0, name: 'Iron Trapdoor' }],
// ── Emerald ──
['129', { bedrock: 'emerald_ore', data: 0, name: 'Emerald Ore' }],
['133', { bedrock: 'emerald_block', data: 0, name: 'Block of Emerald' }],
// ── End Stone ──
['121', { bedrock: 'end_stone', data: 0, name: 'End Stone' }],
['206', { bedrock: 'end_bricks', data: 0, name: 'End Stone Bricks' }],
// ── Purpur ──
['201', { bedrock: 'purpur_block', data: 0, name: 'Purpur Block' }],
['202', { bedrock: 'purpur_pillar', data: 0, name: 'Purpur Pillar' }],
['203', { bedrock: 'purpur_stairs', data: 0, name: 'Purpur Stairs' }],
// ── Prismarine ──
['168', { bedrock: 'prismarine', data: 0, name: 'Prismarine' }],
['168:1', { bedrock: 'prismarine', data: 1, name: 'Prismarine Bricks' }],
['168:2', { bedrock: 'prismarine', data: 2, name: 'Dark Prismarine' }],
['169', { bedrock: 'sea_lantern', data: 0, name: 'Sea Lantern' }],
// ── Hay Bale ──
['170', { bedrock: 'hay_block', data: 0, name: 'Hay Bale' }],
// ── Anvil ──
['145', { bedrock: 'anvil', data: 0, name: 'Anvil' }],
// ── Slime Block ──
['165', { bedrock: 'slime', data: 0, name: 'Slime Block' }],
// ── Coal Block ──
['173', { bedrock: 'coal_block', data: 0, name: 'Block of Coal' }],
// ── Red Sandstone ──
['179', { bedrock: 'red_sandstone', data: 0, name: 'Red Sandstone' }],
['179:1', { bedrock: 'red_sandstone', data: 1, name: 'Chiseled Red Sandstone' }],
['179:2', { bedrock: 'red_sandstone', data: 2, name: 'Smooth Red Sandstone' }],
['180', { bedrock: 'red_sandstone_stairs', data: 0, name: 'Red Sandstone Stairs' }],
// ── Misc utility blocks ──
['26', { bedrock: 'bed', data: 0, name: 'Bed' }],
['30', { bedrock: 'web', data: 0, name: 'Cobweb' }],
['31', { bedrock: 'tallgrass', data: 1, name: 'Grass' }],
['31:2', { bedrock: 'tallgrass', data: 2, name: 'Fern' }],
['32', { bedrock: 'deadbush', data: 0, name: 'Dead Bush' }],
['37', { bedrock: 'yellow_flower', data: 0, name: 'Dandelion' }],
['38', { bedrock: 'red_flower', data: 0, name: 'Poppy' }],
['38:1', { bedrock: 'red_flower', data: 1, name: 'Blue Orchid' }],
['38:2', { bedrock: 'red_flower', data: 2, name: 'Allium' }],
['38:3', { bedrock: 'red_flower', data: 3, name: 'Azure Bluet' }],
['38:4', { bedrock: 'red_flower', data: 4, name: 'Red Tulip' }],
['38:5', { bedrock: 'red_flower', data: 5, name: 'Orange Tulip' }],
['38:6', { bedrock: 'red_flower', data: 6, name: 'White Tulip' }],
['38:7', { bedrock: 'red_flower', data: 7, name: 'Pink Tulip' }],
['38:8', { bedrock: 'red_flower', data: 8, name: 'Oxeye Daisy' }],
['39', { bedrock: 'brown_mushroom', data: 0, name: 'Brown Mushroom' }],
['40', { bedrock: 'red_mushroom', data: 0, name: 'Red Mushroom' }],
['83', { bedrock: 'reeds', data: 0, name: 'Sugar Cane' }],
['100', { bedrock: 'red_mushroom_block', data: 0, name: 'Red Mushroom Block' }],
['99', { bedrock: 'brown_mushroom_block', data: 0, name: 'Brown Mushroom Block' }],
['104', { bedrock: 'pumpkin_stem', data: 0, name: 'Pumpkin Stem' }],
['106', { bedrock: 'vine', data: 0, name: 'Vines' }],
['110', { bedrock: 'mycelium', data: 0, name: 'Mycelium' }],
['111', { bedrock: 'waterlily', data: 0, name: 'Lily Pad' }],
['115', { bedrock: 'nether_wart', data: 0, name: 'Nether Wart' }],
['116', { bedrock: 'enchanting_table', data: 0, name: 'Enchanting Table' }],
['117', { bedrock: 'brewing_stand', data: 0, name: 'Brewing Stand' }],
['118', { bedrock: 'cauldron', data: 0, name: 'Cauldron' }],
['120', { bedrock: 'end_portal_frame', data: 0, name: 'End Portal Frame' }],
['122', { bedrock: 'dragon_egg', data: 0, name: 'Dragon Egg' }],
['130', { bedrock: 'ender_chest', data: 0, name: 'Ender Chest' }],
['138', { bedrock: 'beacon', data: 0, name: 'Beacon' }],
['166', { bedrock: 'barrier', data: 0, name: 'Barrier' }],
['175', { bedrock: 'double_plant', data: 0, name: 'Sunflower' }],
['175:1', { bedrock: 'double_plant', data: 1, name: 'Lilac' }],
['175:2', { bedrock: 'double_plant', data: 2, name: 'Double Tallgrass' }],
['175:3', { bedrock: 'double_plant', data: 3, name: 'Large Fern' }],
['175:4', { bedrock: 'double_plant', data: 4, name: 'Rose Bush' }],
['175:5', { bedrock: 'double_plant', data: 5, name: 'Peony' }],
['198', { bedrock: 'end_rod', data: 0, name: 'End Rod' }],
['199', { bedrock: 'chorus_plant', data: 0, name: 'Chorus Plant' }],
['200', { bedrock: 'chorus_flower', data: 0, name: 'Chorus Flower' }],
['207', { bedrock: 'beetroot', data: 0, name: 'Beetroot' }],
['208', { bedrock: 'grass_path', data: 0, name: 'Grass Path' }],
['209', { bedrock: 'end_gateway', data: 0, name: 'End Gateway' }],
['213', { bedrock: 'magma', data: 0, name: 'Magma Block' }],
['214', { bedrock: 'nether_wart_block', data: 0, name: 'Nether Wart Block' }],
['215', { bedrock: 'red_nether_brick', data: 0, name: 'Red Nether Bricks' }],
['216', { bedrock: 'bone_block', data: 0, name: 'Bone Block' }],
['218', { bedrock: 'observer', data: 0, name: 'Observer' }],
['219', { bedrock: 'shulker_box', data: 0, name: 'White Shulker Box' }],
// ── Walls ──
['139', { bedrock: 'cobblestone_wall', data: 0, name: 'Cobblestone Wall' }],
['139:1', { bedrock: 'cobblestone_wall', data: 1, name: 'Mossy Cobblestone Wall' }],
// ── Banners ──
['176', { bedrock: 'standing_banner', data: 0, name: 'Banner' }],
// ── Signs ──
['63', { bedrock: 'standing_sign', data: 0, name: 'Sign' }],
['68', { bedrock: 'wall_sign', data: 0, name: 'Wall Sign' }],
// ── Flower Pot ──
['140', { bedrock: 'flower_pot', data: 0, name: 'Flower Pot' }],
// ── Skull / Head ──
['144', { bedrock: 'skull', data: 0, name: 'Mob Head' }],
// ── Armor Stand (entity, but GrabCraft uses it) ──
['416', { bedrock: 'air', data: 0, name: 'Armor Stand (entity)' }],
]);
/**
* Name-based fuzzy lookup table (lowercase name -> bedrock ID).
* Built from BLOCK_MAP for fallback matching when numeric ID fails.
*/
const NAME_MAP = new Map();
for (const [, entry] of BLOCK_MAP) {
NAME_MAP.set(entry.name.toLowerCase(), entry);
}
// Additional common name aliases
const ALIASES = new Map([
['dark oak wood plank', BLOCK_MAP.get('5:5')],
['oak wood plank', BLOCK_MAP.get('5')],
['spruce wood plank', BLOCK_MAP.get('5:1')],
['birch wood plank', BLOCK_MAP.get('5:2')],
['jungle wood plank', BLOCK_MAP.get('5:3')],
['acacia wood plank', BLOCK_MAP.get('5:4')],
['dark oak wood', BLOCK_MAP.get('162:1')],
['oak wood', BLOCK_MAP.get('17')],
['spruce wood', BLOCK_MAP.get('17:1')],
['birch wood', BLOCK_MAP.get('17:2')],
['jungle wood', BLOCK_MAP.get('17:3')],
['acacia wood', BLOCK_MAP.get('162')],
['stone brick', BLOCK_MAP.get('98')],
['mossy stone brick', BLOCK_MAP.get('98:1')],
['cracked stone brick', BLOCK_MAP.get('98:2')],
['chiseled stone brick', BLOCK_MAP.get('98:3')],
['brick', BLOCK_MAP.get('45')],
['nether brick', BLOCK_MAP.get('112')],
['glass pane', BLOCK_MAP.get('102')],
['cobble', BLOCK_MAP.get('4')],
['plank', BLOCK_MAP.get('5')],
['planks', BLOCK_MAP.get('5')],
['wooden plank', BLOCK_MAP.get('5')],
['wooden planks', BLOCK_MAP.get('5')],
['log', BLOCK_MAP.get('17')],
['wood', BLOCK_MAP.get('17')],
['leaves', BLOCK_MAP.get('18')],
['torch', BLOCK_MAP.get('50')],
['crafting table', BLOCK_MAP.get('58')],
['workbench', BLOCK_MAP.get('58')],
['furnace', BLOCK_MAP.get('61')],
['chest', BLOCK_MAP.get('54')],
['door', BLOCK_MAP.get('64')],
['fence', BLOCK_MAP.get('85')],
['wool', BLOCK_MAP.get('35')],
['carpet', BLOCK_MAP.get('171')],
['glass', BLOCK_MAP.get('20')],
['sand', BLOCK_MAP.get('12')],
['gravel', BLOCK_MAP.get('13')],
['dirt', BLOCK_MAP.get('3')],
['grass', BLOCK_MAP.get('2')],
['grass block', BLOCK_MAP.get('2')],
['water', BLOCK_MAP.get('8')],
['lava', BLOCK_MAP.get('10')],
['cobblestone wall', BLOCK_MAP.get('139')],
['mossy cobblestone wall', BLOCK_MAP.get('139:1')],
['redstone', BLOCK_MAP.get('55')],
['redstone lamp', BLOCK_MAP.get('123')],
['glowstone', BLOCK_MAP.get('89')],
['sea lantern', BLOCK_MAP.get('169')],
]);
for (const [name, entry] of ALIASES) {
if (entry) NAME_MAP.set(name, entry);
}
/** Track unknown blocks for reporting */
const unknownBlocks = new Map();
/**
* Resolve a GrabCraft block ID and/or name to a Bedrock setblock string.
* @param {string} gcId - GrabCraft numeric ID like "5:5" or "5"
* @param {string} [gcName] - English name from GrabCraft for fuzzy matching
* @returns {{ block: string, data: number, matched: boolean, name: string }}
*/
export function resolveBlock(gcId, gcName) {
// Try exact numeric ID first
if (gcId && BLOCK_MAP.has(gcId)) {
const entry = BLOCK_MAP.get(gcId);
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
}
// Try base ID without data value
if (gcId && gcId.includes(':')) {
const baseId = gcId.split(':')[0];
if (BLOCK_MAP.has(baseId)) {
const entry = BLOCK_MAP.get(baseId);
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
}
}
// Try name-based lookup
if (gcName) {
const lower = gcName.toLowerCase().trim();
if (NAME_MAP.has(lower)) {
const entry = NAME_MAP.get(lower);
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
}
// Fuzzy: try removing common suffixes/prefixes
const simplified = lower
.replace(/\b(block of|block)\b/g, '')
.replace(/\s+/g, ' ')
.trim();
if (NAME_MAP.has(simplified)) {
const entry = NAME_MAP.get(simplified);
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
}
// Partial match: check if any name contains the search term
for (const [name, entry] of NAME_MAP) {
if (name.includes(lower) || lower.includes(name)) {
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
}
}
}
// Track unknown block
const key = `${gcId || 'unknown'}:${gcName || 'unnamed'}`;
unknownBlocks.set(key, (unknownBlocks.get(key) || 0) + 1);
// Fallback to stone
log(TAG, `Unknown block: id=${gcId}, name=${gcName} — using stone fallback`);
return { block: 'stone', data: 0, matched: false, name: gcName || `Unknown(${gcId})` };
}
/**
* Format a resolved block for a setblock command.
* @param {{ block: string, data: number }} resolved
* @returns {string} e.g. "planks 5" or "stone 0"
*/
export function formatBlock(resolved) {
return resolved.data > 0 ? `${resolved.block} ${resolved.data}` : resolved.block;
}
/**
* Get all unknown blocks encountered so far (for reporting).
* @returns {Map<string, number>}
*/
export function getUnknownBlocks() {
return new Map(unknownBlocks);
}
/**
* Clear the unknown blocks tracker.
*/
export function clearUnknownBlocks() {
unknownBlocks.clear();
}
/**
* Get all known block mappings (for MCP resource).
* @returns {Array<{ javaId: string, bedrockId: string, bedrockData: number, name: string }>}
*/
export function getAllBlocks() {
const blocks = [];
for (const [javaId, entry] of BLOCK_MAP) {
blocks.push({
javaId,
bedrockId: entry.bedrock,
bedrockData: entry.data,
name: entry.name,
});
}
return blocks;
}

272
src/building-helpers.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* Higher-level geometric primitives for Minecraft building.
* Generates arrays of setblock/fill commands.
* Uses `fill` for rectangular regions where possible for efficiency.
*/
/**
* Generate a sphere of blocks.
* @param {{ x: number, y: number, z: number }} center
* @param {number} radius
* @param {string} block - Bedrock block ID (e.g. "stone", "glass")
* @param {boolean} [hollow=false] - If true, only the shell
* @returns {string[]} Array of setblock commands
*/
export function generateSphere(center, radius, block, hollow = false) {
const commands = [];
const r2 = radius * radius;
const inner2 = hollow ? (radius - 1) * (radius - 1) : -1;
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
for (let z = -radius; z <= radius; z++) {
const dist2 = x * x + y * y + z * z;
if (dist2 <= r2) {
if (!hollow || dist2 > inner2) {
commands.push(`setblock ${center.x + x} ${center.y + y} ${center.z + z} ${block}`);
}
}
}
}
}
return commands;
}
/**
* Generate a cylinder of blocks.
* @param {{ x: number, y: number, z: number }} base - Bottom center
* @param {number} radius
* @param {number} height
* @param {string} block
* @param {boolean} [hollow=false]
* @returns {string[]} Array of commands (uses fill for full layers)
*/
export function generateCylinder(base, radius, height, block, hollow = false) {
const commands = [];
const r2 = radius * radius;
const inner2 = hollow ? (radius - 1) * (radius - 1) : -1;
for (let y = 0; y < height; y++) {
if (!hollow) {
// For solid cylinders, collect rows and use fill where possible
const rows = collectCircleRows(base.x, base.z, radius);
for (const row of rows) {
commands.push(`fill ${row.x1} ${base.y + y} ${row.z} ${row.x2} ${base.y + y} ${row.z} ${block}`);
}
} else {
for (let x = -radius; x <= radius; x++) {
for (let z = -radius; z <= radius; z++) {
const dist2 = x * x + z * z;
if (dist2 <= r2 && dist2 > inner2) {
commands.push(`setblock ${base.x + x} ${base.y + y} ${base.z + z} ${block}`);
}
}
}
}
}
return commands;
}
/**
* Generate a dome (half-sphere on top of a base point).
* @param {{ x: number, y: number, z: number }} base - Center of the dome base
* @param {number} radius
* @param {string} block
* @returns {string[]} Array of setblock commands
*/
export function generateDome(base, radius, block) {
const commands = [];
const r2 = radius * radius;
const inner2 = (radius - 1) * (radius - 1);
for (let y = 0; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
for (let z = -radius; z <= radius; z++) {
const dist2 = x * x + y * y + z * z;
if (dist2 <= r2 && dist2 > inner2) {
commands.push(`setblock ${base.x + x} ${base.y + y} ${base.z + z} ${block}`);
}
}
}
}
return commands;
}
/**
* Generate a pyramid.
* @param {{ x: number, y: number, z: number }} base - Center of the pyramid base
* @param {number} size - Base half-width
* @param {string} block
* @param {boolean} [hollow=false]
* @returns {string[]} Array of commands (uses fill for layers)
*/
export function generatePyramid(base, size, block, hollow = false) {
const commands = [];
for (let layer = 0; layer <= size; layer++) {
const halfWidth = size - layer;
const y = base.y + layer;
if (halfWidth === 0) {
// Peak - single block
commands.push(`setblock ${base.x} ${y} ${base.z} ${block}`);
} else if (!hollow) {
// Solid layer - single fill command
commands.push(
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth} ${base.x + halfWidth} ${y} ${base.z + halfWidth} ${block}`
);
} else {
// Hollow - only the perimeter of each layer
// Four edges using fill
commands.push(
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth} ${base.x + halfWidth} ${y} ${base.z - halfWidth} ${block}`
);
commands.push(
`fill ${base.x - halfWidth} ${y} ${base.z + halfWidth} ${base.x + halfWidth} ${y} ${base.z + halfWidth} ${block}`
);
if (halfWidth > 1) {
commands.push(
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth + 1} ${base.x - halfWidth} ${y} ${base.z + halfWidth - 1} ${block}`
);
commands.push(
`fill ${base.x + halfWidth} ${y} ${base.z - halfWidth + 1} ${base.x + halfWidth} ${y} ${base.z + halfWidth - 1} ${block}`
);
}
}
}
return commands;
}
/**
* Generate a wall between two points.
* @param {{ x: number, y: number, z: number }} start
* @param {{ x: number, y: number, z: number }} end
* @param {number} height
* @param {string} block
* @returns {string[]} Array of fill commands
*/
export function generateWall(start, end, height, block) {
const commands = [];
// Use fill for the entire wall (works for axis-aligned and diagonal)
const x1 = Math.min(start.x, end.x);
const x2 = Math.max(start.x, end.x);
const z1 = Math.min(start.z, end.z);
const z2 = Math.max(start.z, end.z);
const y1 = Math.min(start.y, end.y);
const y2 = y1 + height - 1;
// If axis-aligned, single fill command
if (x1 === x2 || z1 === z2) {
commands.push(`fill ${x1} ${y1} ${z1} ${x2} ${y2} ${z2} ${block}`);
} else {
// Diagonal wall: use Bresenham-style line of fill columns
const dx = end.x - start.x;
const dz = end.z - start.z;
const steps = Math.max(Math.abs(dx), Math.abs(dz));
for (let i = 0; i <= steps; i++) {
const t = steps === 0 ? 0 : i / steps;
const wx = Math.round(start.x + dx * t);
const wz = Math.round(start.z + dz * t);
commands.push(`fill ${wx} ${y1} ${wz} ${wx} ${y2} ${wz} ${block}`);
}
}
return commands;
}
/**
* Generate a box (rectangular prism).
* @param {{ x: number, y: number, z: number }} corner1
* @param {{ x: number, y: number, z: number }} corner2
* @param {string} block
* @param {boolean} [hollow=false]
* @returns {string[]} Array of fill commands
*/
export function generateBox(corner1, corner2, block, hollow = false) {
if (!hollow) {
return [
`fill ${corner1.x} ${corner1.y} ${corner1.z} ${corner2.x} ${corner2.y} ${corner2.z} ${block}`,
];
}
// Hollow box: 6 faces
const x1 = Math.min(corner1.x, corner2.x);
const x2 = Math.max(corner1.x, corner2.x);
const y1 = Math.min(corner1.y, corner2.y);
const y2 = Math.max(corner1.y, corner2.y);
const z1 = Math.min(corner1.z, corner2.z);
const z2 = Math.max(corner1.z, corner2.z);
return [
// Bottom and top faces
`fill ${x1} ${y1} ${z1} ${x2} ${y1} ${z2} ${block}`,
`fill ${x1} ${y2} ${z1} ${x2} ${y2} ${z2} ${block}`,
// Front and back walls
`fill ${x1} ${y1 + 1} ${z1} ${x2} ${y2 - 1} ${z1} ${block}`,
`fill ${x1} ${y1 + 1} ${z2} ${x2} ${y2 - 1} ${z2} ${block}`,
// Left and right walls
`fill ${x1} ${y1 + 1} ${z1 + 1} ${x1} ${y2 - 1} ${z2 - 1} ${block}`,
`fill ${x2} ${y1 + 1} ${z1 + 1} ${x2} ${y2 - 1} ${z2 - 1} ${block}`,
];
}
/**
* Collect circle rows for efficient fill commands.
* Returns horizontal line segments that fill a circular cross-section.
*/
function collectCircleRows(cx, cz, radius) {
const rows = [];
const r2 = radius * radius;
for (let z = -radius; z <= radius; z++) {
// Find the x extent at this z
const maxX = Math.floor(Math.sqrt(r2 - z * z));
if (maxX >= 0) {
rows.push({ x1: cx - maxX, x2: cx + maxX, z: cz + z });
}
}
return rows;
}
/**
* Mapping of shape names to generator functions and their parameters.
*/
export const SHAPES = {
sphere: {
generate: generateSphere,
params: ['center', 'radius', 'block', 'hollow'],
description: 'A sphere centered at a point',
},
cylinder: {
generate: generateCylinder,
params: ['base', 'radius', 'height', 'block', 'hollow'],
description: 'A cylinder from a base point upward',
},
dome: {
generate: generateDome,
params: ['base', 'radius', 'block'],
description: 'A half-sphere dome from a base point',
},
pyramid: {
generate: generatePyramid,
params: ['base', 'size', 'block', 'hollow'],
description: 'A pyramid from a base center point',
},
wall: {
generate: generateWall,
params: ['start', 'end', 'height', 'block'],
description: 'A wall between two points',
},
box: {
generate: generateBox,
params: ['corner1', 'corner2', 'block', 'hollow'],
description: 'A rectangular box between two corners',
},
};

View File

@@ -14,12 +14,14 @@ export class CommandQueue {
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
* @param {number} opts.maxBuildCommands - Max commands per build (default from env or 5000)
*/
constructor(opts = {}) {
this.maxInFlight = opts.maxInFlight ?? 80;
this.throttleMs = opts.throttleMs ?? 50;
this.batchSize = opts.batchSize ?? 20;
this.batchDelayMs = opts.batchDelayMs ?? 200;
this.maxBuildCommands = opts.maxBuildCommands ?? parseInt(process.env.MAX_BUILD_COMMANDS || '5000', 10);
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
this._pending = new Map();
@@ -28,6 +30,7 @@ export class CommandQueue {
this._sendFn = null;
this._totalSent = 0;
this._totalCompleted = 0;
this._cancelBuild = false;
}
/**
@@ -104,6 +107,63 @@ export class CommandQueue {
return { total: commands.length, succeeded, failed, results };
}
/**
* Enqueue a batch with progress reporting.
* Calls progressFn with status updates between layer batches.
* @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}>}
*/
async enqueueBatchWithProgress(commands, progressFn) {
this._cancelBuild = false;
const results = [];
let succeeded = 0;
let failed = 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 batch = commands.slice(i, i + this.batchSize);
const batchResults = await Promise.allSettled(
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
succeeded++;
results.push(result.value);
} else {
failed++;
results.push({ error: result.reason?.message || 'unknown error' });
}
}
// Report progress
const completed = i + batch.length;
const percent = Math.round((completed / commands.length) * 100);
if (progressFn) {
progressFn({ completed, total: commands.length, percent });
}
// Delay between batches (except after the last one)
if (i + this.batchSize < commands.length) {
await this._delay(this.batchDelayMs);
}
}
return { total: commands.length, succeeded, failed, cancelled: false, results };
}
/** Cancel an in-progress build */
cancelBuild() {
this._cancelBuild = true;
}
/** Process queued commands respecting rate limits */
async _processQueue() {
if (this._processing) return;

107
src/encryption.js Normal file
View File

@@ -0,0 +1,107 @@
import { createECDH, createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { log, logError } from './utils.js';
const TAG = 'Encryption';
// ASN.1 DER header for a P-384 (secp384r1) uncompressed public key
const ASN1_HEADER = Buffer.from(
'3076301006072a8648ce3d020106052b81040022036200',
'hex'
);
/**
* Handles Bedrock's application-level encryption handshake.
*
* Protocol:
* 1. Server generates ECDH keypair on secp384r1
* 2. Server sends `enableencryption` with its public key + random salt
* 3. Client responds with its public key
* 4. Both derive: key = SHA-256(salt + ECDH_shared_secret)
* 5. IV = key[0..16], cipher = AES-256-CFB8 (streaming, stateful)
*/
export class ServerEncryption {
constructor() {
this._ecdh = createECDH('secp384r1');
this._ecdh.generateKeys();
this._salt = randomBytes(16);
/** @type {import('node:crypto').Cipher | null} */
this._cipher = null;
/** @type {import('node:crypto').Decipher | null} */
this._decipher = null;
this._enabled = false;
}
/**
* Get the parameters needed for the enableencryption command.
* @returns {{ publicKey: string, salt: string }} base64-encoded values
*/
getKeyExchangeParams() {
// Bedrock expects the public key wrapped in ASN.1 DER format
const rawPub = this._ecdh.getPublicKey();
const derPub = Buffer.concat([ASN1_HEADER, rawPub]);
return {
publicKey: derPub.toString('base64'),
salt: this._salt.toString('base64'),
};
}
/**
* Complete the key exchange using the client's public key.
* After this call, encrypt() and decrypt() are operational.
* @param {string} clientPubKeyBase64 - Client's base64-encoded public key (may have ASN.1 header)
*/
completeKeyExchange(clientPubKeyBase64) {
let clientPubRaw = Buffer.from(clientPubKeyBase64, 'base64');
// Strip ASN.1 header if present (Bedrock sends the raw key wrapped in DER)
if (clientPubRaw.length > 97) {
// P-384 uncompressed point is 97 bytes (0x04 + 48 + 48)
clientPubRaw = clientPubRaw.slice(clientPubRaw.length - 97);
}
const sharedSecret = this._ecdh.computeSecret(clientPubRaw);
// key = SHA-256(salt + shared_secret)
const hash = createHash('sha256');
hash.update(this._salt);
hash.update(sharedSecret);
const key = hash.digest();
// IV = first 16 bytes of key
const iv = key.slice(0, 16);
this._cipher = createCipheriv('aes-256-cfb8', key, iv);
this._decipher = createDecipheriv('aes-256-cfb8', key, iv);
this._enabled = true;
log(TAG, 'Encryption handshake complete!');
}
/**
* Encrypt a plaintext message.
* @param {string|Buffer} plaintext
* @returns {Buffer} ciphertext
*/
encrypt(plaintext) {
if (!this._cipher) throw new Error('Encryption not initialized');
const input = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
return this._cipher.update(input);
}
/**
* Decrypt a ciphertext message.
* @param {Buffer} ciphertext
* @returns {string} plaintext UTF-8 string
*/
decrypt(ciphertext) {
if (!this._decipher) throw new Error('Encryption not initialized');
return this._decipher.update(ciphertext).toString('utf8');
}
/** @returns {boolean} Whether encryption is active */
get enabled() {
return this._enabled;
}
}

View File

@@ -1,11 +1,12 @@
/**
* In-memory ring buffer for Minecraft game events.
* Privacy-first: no disk persistence, lost on restart.
* Privacy-first: no disk persistence by default, lost on restart.
* Capacity configurable via EVENT_BUFFER_SIZE env var.
*/
export class EventStore {
/** @param {number} capacity - Maximum events to retain */
constructor(capacity = 100) {
this._capacity = capacity;
constructor(capacity) {
this._capacity = capacity ?? parseInt(process.env.EVENT_BUFFER_SIZE || '1000', 10);
/** @type {Array<{type: string, timestamp: string, data: object}>} */
this._events = [];
}
@@ -49,6 +50,31 @@ export class EventStore {
.slice(-count);
}
/**
* Get recent events filtered by multiple types.
* @param {string[]} types - Event types to include
* @param {number} count - Max number to return
* @returns {Array}
*/
getByTypes(types, count = 20) {
const typeSet = new Set(types);
return this._events
.filter((e) => typeSet.has(e.type))
.slice(-count);
}
/**
* Get events since a given timestamp.
* @param {string} timestamp - ISO timestamp
* @param {number} count - Max number to return
* @returns {Array}
*/
getSince(timestamp, count = 100) {
return this._events
.filter((e) => e.timestamp >= timestamp)
.slice(-count);
}
/** @returns {number} Total events currently stored */
get size() {
return this._events.length;

396
src/grabcraft.js Normal file
View File

@@ -0,0 +1,396 @@
import { log, logError } from './utils.js';
import { resolveBlock, formatBlock, getUnknownBlocks, clearUnknownBlocks } from './block-map.js';
const TAG = 'GrabCraft';
const GRABCRAFT_BASE = 'https://www.grabcraft.com';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* LRU cache for fetched blueprints.
*/
class LRUCache {
constructor(maxSize = 50, ttlMs = 3600000) {
this._maxSize = maxSize;
this._ttlMs = ttlMs;
/** @type {Map<string, { data: any, timestamp: number }>} */
this._cache = new Map();
}
get(key) {
const entry = this._cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this._ttlMs) {
this._cache.delete(key);
return null;
}
// Move to end (most recently used)
this._cache.delete(key);
this._cache.set(key, entry);
return entry.data;
}
set(key, data) {
this._cache.delete(key);
if (this._cache.size >= this._maxSize) {
// Delete oldest (first entry)
const firstKey = this._cache.keys().next().value;
this._cache.delete(firstKey);
}
this._cache.set(key, { data, timestamp: Date.now() });
}
}
const blueprintCache = new LRUCache(50, 3600000);
/**
* Search GrabCraft for blueprints matching a query.
* @param {string} query - Search term
* @param {number} [page=1] - Page number
* @returns {Promise<{ results: Array<{ name: string, url: string, category: string, blocks: string }>, total: number, page: number }>}
*/
export async function searchBlueprints(query, page = 1) {
const searchUrl = `${GRABCRAFT_BASE}/search/${encodeURIComponent(query)}/${page}`;
log(TAG, `Searching: ${searchUrl}`);
const html = await fetchPage(searchUrl);
const results = [];
// GrabCraft search results are in div.browse-item or similar card elements
// Pattern: <a href="/minecraft/...">...<div class="title">Name</div>...
const itemRegex = /<a[^>]+href="(\/minecraft\/[^"]+)"[^>]*>[\s\S]*?<div[^>]*class="[^"]*browse-item-title[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let match;
while ((match = itemRegex.exec(html)) !== null) {
const url = GRABCRAFT_BASE + match[1];
const name = match[2].replace(/<[^>]+>/g, '').trim();
// Try to extract category and block count from surrounding context
const contextStart = Math.max(0, match.index - 500);
const contextEnd = Math.min(html.length, match.index + match[0].length + 500);
const context = html.substring(contextStart, contextEnd);
const categoryMatch = context.match(/category[^>]*>([^<]+)/i);
const blockMatch = context.match(/(\d+)\s*blocks?/i);
results.push({
name,
url,
category: categoryMatch ? categoryMatch[1].trim() : 'Unknown',
blocks: blockMatch ? blockMatch[1] : 'Unknown',
});
}
// Fallback: try alternate HTML structure
if (results.length === 0) {
const altRegex = /<a[^>]+href="(\/minecraft\/[^"]+)"[^>]*class="[^"]*"[^>]*>[\s\S]*?<[^>]+>([^<]{3,})<\/[^>]+>/gi;
while ((match = altRegex.exec(html)) !== null) {
const url = GRABCRAFT_BASE + match[1];
const name = match[2].replace(/<[^>]+>/g, '').trim();
if (name && name.length > 2 && !name.includes('{') && !name.includes('function')) {
results.push({ name, url, category: 'Unknown', blocks: 'Unknown' });
}
}
}
// Try to get total count
const totalMatch = html.match(/(\d+)\s*results?/i);
const total = totalMatch ? parseInt(totalMatch[1], 10) : results.length;
log(TAG, `Found ${results.length} results (total: ${total})`);
return { results, total, page };
}
/**
* Fetch and parse a GrabCraft blueprint page.
* Extracts the embedded voxel data (myRenderObject), materials, and layer map.
* @param {string} url - Full GrabCraft blueprint URL
* @returns {Promise<object>} Parsed blueprint data
*/
export async function fetchBlueprint(url) {
// Check cache
const cached = blueprintCache.get(url);
if (cached) {
log(TAG, `Cache hit: ${url}`);
return cached;
}
log(TAG, `Fetching blueprint: ${url}`);
const html = await fetchPage(url);
// Extract the name from page title
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
const name = titleMatch
? titleMatch[1].replace(/\s*[-|].*$/, '').replace(/GrabCraft/i, '').trim()
: 'Unknown Blueprint';
// Extract render object (3D voxel data)
const voxels = extractRenderObject(html);
// Extract materials list
const materials = extractMaterials(html);
// Calculate dimensions
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const voxel of voxels) {
minX = Math.min(minX, voxel.x);
minY = Math.min(minY, voxel.y);
minZ = Math.min(minZ, voxel.z);
maxX = Math.max(maxX, voxel.x);
maxY = Math.max(maxY, voxel.y);
maxZ = Math.max(maxZ, voxel.z);
}
const dimensions = voxels.length > 0
? { width: maxX - minX + 1, height: maxY - minY + 1, depth: maxZ - minZ + 1 }
: { width: 0, height: 0, depth: 0 };
const blueprint = {
name,
url,
voxels,
materials,
dimensions,
totalBlocks: voxels.filter(v => v.matId !== '0').length,
origin: { x: minX, y: minY, z: minZ },
};
// Cache it
blueprintCache.set(url, blueprint);
log(TAG, `Parsed "${name}": ${blueprint.totalBlocks} blocks, ${dimensions.width}x${dimensions.height}x${dimensions.depth}`);
return blueprint;
}
/**
* Convert a blueprint to Bedrock setblock commands.
* @param {object} blueprint - Parsed blueprint from fetchBlueprint()
* @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
* @returns {{ commands: string[], summary: object }}
*/
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);
}
const unknowns = getUnknownBlocks();
const summary = {
name: blueprint.name,
totalVoxels: voxels.length,
totalCommands: commands.length,
skippedAir,
dimensions: blueprint.dimensions,
materials: Object.fromEntries(materialCounts),
unmappedBlocks: unknowns.size > 0 ? Object.fromEntries(unknowns) : null,
buildOrigin: { x: originX, y: originY, z: originZ },
};
return { commands, summary };
}
/**
* Extract myRenderObject voxel data from the page HTML.
* GrabCraft embeds data like:
* myRenderObject[y][x][z] = { mat_id: "5:5", rgb: {...}, hex: "#...", ... }
*/
function extractRenderObject(html) {
const voxels = [];
// Pattern 1: myRenderObject[y][x][z] = {...}
const renderRegex = /myRenderObject\[(\d+)\]\[(\d+)\]\[(\d+)\]\s*=\s*(\{[^}]+\})/g;
let match;
while ((match = renderRegex.exec(html)) !== null) {
const y = parseInt(match[1], 10);
const x = parseInt(match[2], 10);
const z = parseInt(match[3], 10);
const objStr = match[4];
const matIdMatch = objStr.match(/mat_id\s*:\s*["']([^"']+)["']/);
const matId = matIdMatch ? matIdMatch[1] : '0';
const hexMatch = objStr.match(/hex\s*:\s*["']([^"']+)["']/);
const hex = hexMatch ? hexMatch[1] : null;
voxels.push({ x, y, z, matId, hex, matName: null });
}
// Pattern 2: layerMap-based data
// layerMap[y] = [{x:..., z:..., mat_id:...}, ...]
if (voxels.length === 0) {
const layerRegex = /layerMap\[(\d+)\]\s*=\s*\[([\s\S]*?)\];/g;
while ((match = layerRegex.exec(html)) !== null) {
const y = parseInt(match[1], 10);
const arrayContent = match[2];
const blockRegex = /\{[^}]*x\s*:\s*(\d+)[^}]*z\s*:\s*(\d+)[^}]*mat_id\s*:\s*["']([^"']+)["'][^}]*\}/g;
let blockMatch;
while ((blockMatch = blockRegex.exec(arrayContent)) !== null) {
voxels.push({
x: parseInt(blockMatch[1], 10),
y,
z: parseInt(blockMatch[2], 10),
matId: blockMatch[3],
hex: null,
matName: null,
});
}
}
}
// Pattern 3: JSON array of blocks
if (voxels.length === 0) {
const jsonRegex = /var\s+blocks?\s*=\s*(\[[\s\S]*?\]);/;
const jsonMatch = html.match(jsonRegex);
if (jsonMatch) {
try {
const blocks = JSON.parse(jsonMatch[1]);
for (const b of blocks) {
voxels.push({
x: b.x || 0,
y: b.y || 0,
z: b.z || 0,
matId: String(b.mat_id || b.id || '0'),
hex: b.hex || null,
matName: b.name || null,
});
}
} catch {
logError(TAG, 'Failed to parse blocks JSON');
}
}
}
log(TAG, `Extracted ${voxels.length} voxels from page`);
// Cross-reference with materials if names are missing
return voxels;
}
/**
* Extract materials list from Highcharts series data.
* GrabCraft pages include chart data like:
* series: [{ name: "Stone", id: "1", data: [{y: 500}], ... }]
*/
function extractMaterials(html) {
const materials = [];
// Pattern: series data in Highcharts config
// Look for objects with id, name, and y (count) fields
const seriesRegex = /\{\s*(?:name|id)\s*:\s*["']([^"']+)["']\s*,\s*(?:id|name)\s*:\s*["']([^"']+)["'][^}]*y\s*:\s*(\d+)/g;
let match;
while ((match = seriesRegex.exec(html)) !== null) {
// Determine which is name vs id based on content
let id, name, count;
if (match[1].match(/^\d/)) {
id = match[1];
name = match[2];
} else {
name = match[1];
id = match[2];
}
count = parseInt(match[3], 10);
materials.push({ id, name, count });
}
// Alternative: look for material list in a different format
if (materials.length === 0) {
const matRegex = /id\s*:\s*["'](\d+(?::\d+)?)["']\s*,\s*name\s*:\s*["']([^"']+)["']\s*,\s*y\s*:\s*(\d+)/g;
while ((match = matRegex.exec(html)) !== null) {
materials.push({
id: match[1],
name: match[2],
count: parseInt(match[3], 10),
});
}
}
log(TAG, `Extracted ${materials.length} materials`);
return materials;
}
/**
* Fetch a page with standard headers.
* @param {string} url
* @returns {Promise<string>} HTML content
*/
async function fetchPage(url) {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}`);
}
return response.text();
}
/**
* Get available GrabCraft categories.
* @returns {Array<{ name: string, slug: string }>}
*/
export function getCategories() {
return [
{ name: 'Houses & Mansions', slug: 'houses' },
{ name: 'Castles & Fortresses', slug: 'castles' },
{ name: 'Medieval Buildings', slug: 'medieval' },
{ name: 'Modern Buildings', slug: 'modern' },
{ name: 'Towers', slug: 'towers' },
{ name: 'Churches & Temples', slug: 'churches' },
{ name: 'Ships & Boats', slug: 'ships' },
{ name: 'Bridges', slug: 'bridges' },
{ name: 'Farms', slug: 'farms' },
{ name: 'Statues & Sculptures', slug: 'statues' },
{ name: 'Pixel Art', slug: 'pixel-art' },
{ name: 'Redstone Devices', slug: 'redstone' },
{ name: 'Gardens & Parks', slug: 'gardens' },
{ name: 'Furniture & Decor', slug: 'furniture' },
{ name: 'Vehicles', slug: 'vehicles' },
{ name: 'Fantasy', slug: 'fantasy' },
{ name: 'Sci-Fi', slug: 'sci-fi' },
{ name: 'Animals', slug: 'animals' },
{ name: 'Trees & Nature', slug: 'trees' },
{ name: 'Underground', slug: 'underground' },
];
}

View File

@@ -4,7 +4,10 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { randomUUID } from 'node:crypto';
import express from 'express';
import { z } from 'zod';
import { log, logError } from './utils.js';
import { log, logError, createCommandMessage } from './utils.js';
import { searchBlueprints, fetchBlueprint, blueprintToCommands, getCategories } from './grabcraft.js';
import { getAllBlocks } from './block-map.js';
import { SHAPES } from './building-helpers.js';
const TAG = 'MCP';
@@ -40,10 +43,10 @@ export function startMcpServer(bedrock, port = 3002) {
const server = new McpServer(
{
name: 'minecraft-bridge',
version: '1.0.0',
version: '2.0.0',
},
{
capabilities: { logging: {} },
capabilities: { logging: {}, resources: {} },
}
);
@@ -130,17 +133,17 @@ export function startMcpServer(bedrock, port = 3002) {
{
title: 'Minecraft Build',
description:
'Execute a batch of build commands (setblock, fill, clone) with rate limiting. Max 200 commands per call. Commands should NOT have a leading slash.',
`Execute a batch of build commands (setblock, fill, clone) with rate limiting. Max ${bedrock.commandQueue.maxBuildCommands} commands per call. Commands should NOT have a leading slash.`,
inputSchema: z.object({
commands: z
.array(z.string())
.max(200)
.max(bedrock.commandQueue.maxBuildCommands)
.describe(
'Array of build commands. E.g. ["setblock 10 64 10 stone", "fill 10 64 10 20 64 20 glass"]'
),
}),
},
async ({ commands }) => {
async ({ commands }, { sendNotification }) => {
// Validate: only allow build commands
const allowed = ['setblock', 'fill', 'clone', 'structure'];
const invalid = commands.filter((cmd) => {
@@ -161,12 +164,31 @@ export function startMcpServer(bedrock, port = 3002) {
}
try {
const result = await bedrock.sendBatch(commands);
const prepared = commands.map((line) => createCommandMessage(line));
const progressFn = (progress) => {
try {
sendNotification({
method: 'notifications/message',
params: {
level: 'info',
logger: 'minecraft-build',
data: `Building: ${progress.percent}% (${progress.completed}/${progress.total})`,
},
});
} catch {
// Notification failures are non-critical
}
};
const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn);
const status = result.cancelled ? 'cancelled' : 'complete';
return {
content: [
{
type: 'text',
text: `Build complete: ${result.succeeded}/${result.total} commands succeeded, ${result.failed} failed`,
text: `Build ${status}: ${result.succeeded}/${result.total} commands succeeded, ${result.failed} failed${result.cancelled ? ' (cancelled)' : ''}`,
},
],
};
@@ -200,13 +222,29 @@ export function startMcpServer(bedrock, port = 3002) {
.describe(
'Filter by event type. E.g. "PlayerMessage", "BlockChanged"'
),
types: z
.array(z.string())
.optional()
.describe('Filter by multiple event types'),
since: z
.string()
.optional()
.describe('ISO timestamp to get events after'),
}),
},
async ({ count, type }) => {
async ({ count, type, types, since }) => {
const n = count ?? 20;
const events = type
? bedrock.events.getByType(type, n)
: bedrock.events.getRecent(n);
let events;
if (since) {
events = bedrock.events.getSince(since, n);
} else if (types && types.length > 0) {
events = bedrock.events.getByTypes(types, n);
} else if (type) {
events = bedrock.events.getByType(type, n);
} else {
events = bedrock.events.getRecent(n);
}
if (events.length === 0) {
return {
@@ -250,6 +288,7 @@ export function startMcpServer(bedrock, port = 3002) {
const status = bedrock.getStatus();
const lines = [
`Connected: ${status.connected ? 'YES' : 'NO'}`,
`Encrypted: ${status.encrypted ? 'YES' : 'NO'}`,
`Player: ${status.playerName || 'unknown'}`,
`Connected since: ${status.connectedAt || 'N/A'}`,
`Subscriptions: ${status.subscriptions.join(', ') || 'none'}`,
@@ -258,6 +297,7 @@ export function startMcpServer(bedrock, port = 3002) {
`Commands completed: ${status.totalCompleted}`,
`Queue size: ${status.queueSize}`,
`In-flight: ${status.inFlight}`,
`Max build commands: ${bedrock.commandQueue.maxBuildCommands}`,
];
return {
@@ -314,6 +354,434 @@ export function startMcpServer(bedrock, port = 3002) {
}
);
// ── Tool: minecraft_get_player_position (Phase 1) ───────────────────
server.registerTool(
'minecraft_get_player_position',
{
title: 'Get Player Position',
description:
'Get the player\'s current position, rotation, and dimension in the world.',
inputSchema: z.object({}),
},
async () => {
try {
const pos = await bedrock.getPlayerPosition();
return {
content: [
{
type: 'text',
text: `Position: x=${pos.x}, y=${pos.y}, z=${pos.z}\nRotation: rx=${pos.rx}, ry=${pos.ry}\nDimension: ${pos.dimension} (0=Overworld, 1=Nether, 2=End)`,
},
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: minecraft_test_for_block (Phase 1) ────────────────────────
server.registerTool(
'minecraft_test_for_block',
{
title: 'Test For Block',
description:
'Test if a specific block exists at given coordinates. Useful for checking what block is at a location.',
inputSchema: z.object({
x: z.number().int().describe('X coordinate'),
y: z.number().int().describe('Y coordinate'),
z: z.number().int().describe('Z coordinate'),
blockId: z
.string()
.optional()
.describe('Block ID to test for (e.g. "stone", "air"). Omit to get block at position.'),
}),
},
async ({ x, y, z: zCoord, blockId }) => {
try {
const response = await bedrock.testForBlock(x, y, zCoord, blockId);
const statusMessage = response?.statusMessage || 'Test complete';
const statusCode = response?.statusCode ?? -1;
return {
content: [
{
type: 'text',
text: `[${statusCode === 0 ? 'MATCH' : 'NO MATCH'}] ${statusMessage}`,
},
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: minecraft_search_blueprints (Phase 2) ─────────────────────
server.registerTool(
'minecraft_search_blueprints',
{
title: 'Search GrabCraft Blueprints',
description:
'Search GrabCraft.com for Minecraft building blueprints. Returns names, URLs, and block counts. Use the URL with minecraft_build_blueprint to construct the building.',
inputSchema: z.object({
query: z.string().describe('Search query, e.g. "medieval house", "castle", "modern apartment"'),
page: z.number().int().min(1).optional().describe('Page number (default 1)'),
}),
},
async ({ query, page }) => {
try {
const results = await searchBlueprints(query, page ?? 1);
if (results.results.length === 0) {
return {
content: [
{
type: 'text',
text: `No blueprints found for "${query}". Try a different search term.`,
},
],
};
}
const formatted = results.results
.map((r, i) => `${i + 1}. ${r.name}\n Blocks: ${r.blocks} | Category: ${r.category}\n URL: ${r.url}`)
.join('\n\n');
return {
content: [
{
type: 'text',
text: `Found ${results.total} blueprints (page ${results.page}):\n\n${formatted}`,
},
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error searching blueprints: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: minecraft_build_blueprint (Phase 2) ───────────────────────
server.registerTool(
'minecraft_build_blueprint',
{
title: 'Build GrabCraft Blueprint',
description:
'Fetch a GrabCraft blueprint and build it in Minecraft. If no coordinates given, builds at the player\'s current position. Use dryRun to preview materials and dimensions without building.',
inputSchema: z.object({
url: z.string().describe('GrabCraft blueprint URL'),
x: z.number().int().optional().describe('Build origin X (default: player position)'),
y: z.number().int().optional().describe('Build origin Y (default: player position)'),
z: z.number().int().optional().describe('Build origin Z (default: player position)'),
dryRun: z.boolean().optional().describe('If true, returns material list and dimensions without building'),
}),
},
async ({ url, x, y, z: zCoord, dryRun }, { sendNotification }) => {
try {
// Determine build origin
let originX = x, originY = y, originZ = zCoord;
if (originX === undefined || originY === undefined || originZ === undefined) {
try {
const pos = await bedrock.getPlayerPosition();
originX = originX ?? pos.x;
originY = originY ?? pos.y;
originZ = originZ ?? pos.z;
} catch {
return {
content: [
{
type: 'text',
text: 'Error: Could not get player position. Specify x, y, z coordinates manually or ensure Minecraft is connected.',
},
],
isError: true,
};
}
}
// Fetch and parse blueprint
const blueprint = await fetchBlueprint(url);
if (blueprint.voxels.length === 0) {
return {
content: [
{
type: 'text',
text: `Blueprint "${blueprint.name}" has no voxel data. The page structure may have changed.`,
},
],
isError: true,
};
}
// Convert to commands
const { commands, summary } = blueprintToCommands(blueprint, originX, originY, originZ);
if (dryRun) {
const materialLines = Object.entries(summary.materials)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ` ${name}: ${count}`)
.join('\n');
let text = `Blueprint: ${summary.name}\n`;
text += `Dimensions: ${summary.dimensions.width}W x ${summary.dimensions.height}H x ${summary.dimensions.depth}D\n`;
text += `Total blocks: ${summary.totalCommands}\n`;
text += `Build origin: ${summary.buildOrigin.x}, ${summary.buildOrigin.y}, ${summary.buildOrigin.z}\n\n`;
text += `Materials:\n${materialLines}`;
if (summary.unmappedBlocks) {
text += `\n\nUnmapped blocks (using stone fallback):\n`;
text += Object.entries(summary.unmappedBlocks)
.map(([k, v]) => ` ${k}: ${v}`)
.join('\n');
}
return { content: [{ type: 'text', text }] };
}
// Check command limit
if (commands.length > bedrock.commandQueue.maxBuildCommands) {
return {
content: [
{
type: 'text',
text: `Blueprint has ${commands.length} commands, exceeding the limit of ${bedrock.commandQueue.maxBuildCommands}. Use dryRun to preview, or increase MAX_BUILD_COMMANDS.`,
},
],
isError: true,
};
}
// Build it
const prepared = commands.map((line) => createCommandMessage(line));
const progressFn = (progress) => {
try {
sendNotification({
method: 'notifications/message',
params: {
level: 'info',
logger: 'minecraft-blueprint',
data: `Building "${summary.name}": ${progress.percent}% (${progress.completed}/${progress.total})`,
},
});
} catch {
// Non-critical
}
};
const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn);
let text = `Blueprint "${summary.name}" build ${result.cancelled ? 'cancelled' : 'complete'}!\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`;
text += `Build origin: ${summary.buildOrigin.x}, ${summary.buildOrigin.y}, ${summary.buildOrigin.z}`;
if (summary.unmappedBlocks) {
text += `\n\nUnmapped blocks (used stone): ${Object.keys(summary.unmappedBlocks).join(', ')}`;
}
return { content: [{ type: 'text', text }] };
} catch (err) {
return {
content: [{ type: 'text', text: `Error building blueprint: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: minecraft_build_shape (Phase 4) ───────────────────────────
server.registerTool(
'minecraft_build_shape',
{
title: 'Build Geometric Shape',
description:
`Build a geometric shape in Minecraft. Available shapes: ${Object.keys(SHAPES).join(', ')}. Each shape requires specific parameters - use the shape name to see required params.`,
inputSchema: z.object({
shape: z.enum(['sphere', 'cylinder', 'dome', 'pyramid', 'wall', 'box'])
.describe('Shape type to build'),
block: z.string().describe('Bedrock block ID, e.g. "stone", "glass", "diamond_block"'),
hollow: z.boolean().optional().describe('Make the shape hollow (default false)'),
// Point parameters - used depending on shape
x: z.number().int().optional().describe('Center/base/start X (default: player position)'),
y: z.number().int().optional().describe('Center/base/start Y (default: player position)'),
z: z.number().int().optional().describe('Center/base/start Z (default: player position)'),
// For shapes needing a second point (wall, box)
x2: z.number().int().optional().describe('End/corner2 X (wall, box)'),
y2: z.number().int().optional().describe('End/corner2 Y (box)'),
z2: z.number().int().optional().describe('End/corner2 Z (wall, box)'),
// Size parameters
radius: z.number().int().min(1).max(50).optional().describe('Radius (sphere, cylinder, dome)'),
height: z.number().int().min(1).max(100).optional().describe('Height (cylinder, wall)'),
size: z.number().int().min(1).max(50).optional().describe('Base half-width (pyramid)'),
}),
},
async ({ shape, block, hollow, x, y, z: zCoord, x2, y2, z2, radius, height, size }, { sendNotification }) => {
try {
// Get player position as default
let px = x, py = y, pz = zCoord;
if (px === undefined || py === undefined || pz === undefined) {
try {
const pos = await bedrock.getPlayerPosition();
px = px ?? pos.x;
py = py ?? pos.y;
pz = pz ?? pos.z;
} catch {
return {
content: [{ type: 'text', text: 'Error: Could not get player position. Specify coordinates manually.' }],
isError: true,
};
}
}
let commands;
const point = { x: px, y: py, z: pz };
switch (shape) {
case 'sphere':
if (!radius) return { content: [{ type: 'text', text: 'Error: radius is required for sphere' }], isError: true };
commands = SHAPES.sphere.generate(point, radius, block, hollow ?? false);
break;
case 'cylinder':
if (!radius || !height) return { content: [{ type: 'text', text: 'Error: radius and height are required for cylinder' }], isError: true };
commands = SHAPES.cylinder.generate(point, radius, height, block, hollow ?? false);
break;
case 'dome':
if (!radius) return { content: [{ type: 'text', text: 'Error: radius is required for dome' }], isError: true };
commands = SHAPES.dome.generate(point, radius, block);
break;
case 'pyramid':
if (!size) return { content: [{ type: 'text', text: 'Error: size is required for pyramid' }], isError: true };
commands = SHAPES.pyramid.generate(point, size, block, hollow ?? false);
break;
case 'wall':
if (x2 === undefined || z2 === undefined || !height) return { content: [{ type: 'text', text: 'Error: x2, z2, and height are required for wall' }], isError: true };
commands = SHAPES.wall.generate(point, { x: x2, y: py, z: z2 }, height, block);
break;
case 'box':
if (x2 === undefined || y2 === undefined || z2 === undefined) return { content: [{ type: 'text', text: 'Error: x2, y2, z2 are required for box' }], isError: true };
commands = SHAPES.box.generate(point, { x: x2, y: y2, z: z2 }, block, hollow ?? false);
break;
default:
return { content: [{ type: 'text', text: `Unknown shape: ${shape}` }], isError: true };
}
if (commands.length > bedrock.commandQueue.maxBuildCommands) {
return {
content: [{ type: 'text', text: `Shape would require ${commands.length} commands, exceeding limit of ${bedrock.commandQueue.maxBuildCommands}. Try a smaller size.` }],
isError: true,
};
}
// Execute
const prepared = commands.map((line) => createCommandMessage(line));
const progressFn = (progress) => {
try {
sendNotification({
method: 'notifications/message',
params: {
level: 'info',
logger: 'minecraft-shape',
data: `Building ${shape}: ${progress.percent}% (${progress.completed}/${progress.total})`,
},
});
} catch { /* non-critical */ }
};
const result = await bedrock.commandQueue.enqueueBatchWithProgress(prepared, progressFn);
return {
content: [
{
type: 'text',
text: `${shape} build ${result.cancelled ? 'cancelled' : 'complete'}: ${result.succeeded}/${result.total} commands succeeded, ${result.failed} failed`,
},
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: minecraft_cancel_build ─────────────────────────────────────
server.registerTool(
'minecraft_cancel_build',
{
title: 'Cancel Build',
description: 'Cancel an in-progress build operation (blueprint or shape build).',
inputSchema: z.object({}),
},
async () => {
bedrock.commandQueue.cancelBuild();
return {
content: [{ type: 'text', text: 'Build cancellation requested. The current batch will finish before stopping.' }],
};
}
);
// ── MCP Resources (Phase 6) ─────────────────────────────────────────
// Resource: Block ID reference
server.resource(
'blocks',
'minecraft://blocks',
{
description: 'Block ID mapping reference (Java Edition ID -> Bedrock Edition ID)',
mimeType: 'application/json',
},
async () => {
const blocks = getAllBlocks();
return {
contents: [
{
uri: 'minecraft://blocks',
mimeType: 'application/json',
text: JSON.stringify(blocks, null, 2),
},
],
};
}
);
// Resource: GrabCraft categories
server.resource(
'grabcraft-categories',
'grabcraft://categories',
{
description: 'Available GrabCraft blueprint categories for searching',
mimeType: 'application/json',
},
async () => {
const categories = getCategories();
return {
contents: [
{
uri: 'grabcraft://categories',
mimeType: 'application/json',
text: JSON.stringify(categories, null, 2),
},
],
};
}
);
return server;
}

View File

@@ -48,6 +48,34 @@ export function createCommandMessage(commandLine) {
return { id: requestId, message };
}
/**
* Create a Bedrock WebSocket enableencryption command message.
* @param {string} publicKeyBase64 - Server's base64-encoded public key
* @param {string} saltBase64 - Base64-encoded 16-byte salt
* @returns {{ id: string, message: string }} request ID and JSON string
*/
export function createEnableEncryptionMessage(publicKeyBase64, saltBase64) {
const requestId = randomUUID();
const message = JSON.stringify({
header: {
version: 1,
requestId,
messageType: 'commandRequest',
messagePurpose: 'commandRequest',
},
body: {
version: 1,
commandLine: `enableencryption "${publicKeyBase64}" "${saltBase64}"`,
origin: {
type: 'player',
},
},
});
return { id: requestId, message };
}
/**
* Strip Minecraft formatting codes (section sign + character).
* @param {string} text