feat: add GrabCraft blueprints, building helpers, and world state awareness
All checks were successful
Deploy to Docker / deploy (push) Successful in 12s
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:
@@ -1,13 +1,15 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
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 { EventStore } from './event-store.js';
|
||||||
import { CommandQueue } from './command-queue.js';
|
import { CommandQueue } from './command-queue.js';
|
||||||
|
import { ServerEncryption } from './encryption.js';
|
||||||
|
|
||||||
const TAG = 'BedrockWS';
|
const TAG = 'BedrockWS';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
||||||
* Only one Minecraft client is supported at a time.
|
* Only one Minecraft client is supported at a time.
|
||||||
|
* Supports Bedrock's application-level encryption handshake.
|
||||||
*/
|
*/
|
||||||
export class BedrockWebSocket {
|
export class BedrockWebSocket {
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +18,7 @@ export class BedrockWebSocket {
|
|||||||
*/
|
*/
|
||||||
constructor(opts = {}) {
|
constructor(opts = {}) {
|
||||||
this.port = opts.port ?? 3001;
|
this.port = opts.port ?? 3001;
|
||||||
this.events = new EventStore(100);
|
this.events = new EventStore();
|
||||||
this.commandQueue = new CommandQueue();
|
this.commandQueue = new CommandQueue();
|
||||||
|
|
||||||
/** @type {import('ws').WebSocket | null} */
|
/** @type {import('ws').WebSocket | null} */
|
||||||
@@ -25,11 +27,26 @@ export class BedrockWebSocket {
|
|||||||
this._connectedAt = null;
|
this._connectedAt = null;
|
||||||
this._playerName = null;
|
this._playerName = null;
|
||||||
this._subscriptions = new Set();
|
this._subscriptions = new Set();
|
||||||
|
|
||||||
|
// Encryption state
|
||||||
|
/** @type {ServerEncryption | null} */
|
||||||
|
this._encryption = null;
|
||||||
|
this._pendingEncryption = false;
|
||||||
|
this._encryptionRequestId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start the WebSocket server */
|
/** Start the WebSocket server */
|
||||||
start() {
|
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', () => {
|
this._wss.on('listening', () => {
|
||||||
log(TAG, `WebSocket server listening on port ${this.port}`);
|
log(TAG, `WebSocket server listening on port ${this.port}`);
|
||||||
@@ -48,21 +65,23 @@ export class BedrockWebSocket {
|
|||||||
this._connectedAt = new Date();
|
this._connectedAt = new Date();
|
||||||
log(TAG, 'Minecraft client connected!');
|
log(TAG, 'Minecraft client connected!');
|
||||||
|
|
||||||
// Wire up command queue to send over this socket
|
// Start encryption handshake BEFORE wiring up command queue
|
||||||
this.commandQueue.setSendFunction((id, message) => {
|
this._beginEncryptionHandshake();
|
||||||
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();
|
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
try {
|
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);
|
this._handleMessage(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(TAG, 'Failed to parse message:', err.message);
|
logError(TAG, 'Failed to parse message:', err.message);
|
||||||
@@ -75,6 +94,9 @@ export class BedrockWebSocket {
|
|||||||
this._connectedAt = null;
|
this._connectedAt = null;
|
||||||
this._playerName = null;
|
this._playerName = null;
|
||||||
this._subscriptions.clear();
|
this._subscriptions.clear();
|
||||||
|
this._encryption = null;
|
||||||
|
this._pendingEncryption = false;
|
||||||
|
this._encryptionRequestId = null;
|
||||||
this.commandQueue.setSendFunction(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 */
|
/** Subscribe to default event types */
|
||||||
_autoSubscribe() {
|
_autoSubscribe() {
|
||||||
const defaultEvents = ['PlayerMessage'];
|
const defaultEvents = ['PlayerMessage'];
|
||||||
@@ -111,7 +171,12 @@ export class BedrockWebSocket {
|
|||||||
return true;
|
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);
|
this._subscriptions.add(eventName);
|
||||||
log(TAG, `Subscribed to ${eventName}`);
|
log(TAG, `Subscribed to ${eventName}`);
|
||||||
return true;
|
return true;
|
||||||
@@ -124,6 +189,33 @@ export class BedrockWebSocket {
|
|||||||
_handleMessage(data) {
|
_handleMessage(data) {
|
||||||
const purpose = data?.header?.messagePurpose;
|
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') {
|
if (purpose === 'commandResponse') {
|
||||||
// Response to a command we sent
|
// Response to a command we sent
|
||||||
const requestId = data.header.requestId;
|
const requestId = data.header.requestId;
|
||||||
@@ -198,10 +290,76 @@ export class BedrockWebSocket {
|
|||||||
return this._ws !== null && this._ws.readyState === 1;
|
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 */
|
/** @returns {object} Status information */
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return {
|
return {
|
||||||
connected: this.isConnected(),
|
connected: this.isConnected(),
|
||||||
|
encrypted: this._encryption?.enabled ?? false,
|
||||||
playerName: this._playerName,
|
playerName: this._playerName,
|
||||||
connectedAt: this._connectedAt?.toISOString() || null,
|
connectedAt: this._connectedAt?.toISOString() || null,
|
||||||
subscriptions: [...this._subscriptions],
|
subscriptions: [...this._subscriptions],
|
||||||
|
|||||||
692
src/block-map.js
Normal file
692
src/block-map.js
Normal 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
272
src/building-helpers.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -14,12 +14,14 @@ export class CommandQueue {
|
|||||||
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
* @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.batchSize - Commands per batch for build mode (default 20)
|
||||||
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
* @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 = {}) {
|
constructor(opts = {}) {
|
||||||
this.maxInFlight = opts.maxInFlight ?? 80;
|
this.maxInFlight = opts.maxInFlight ?? 80;
|
||||||
this.throttleMs = opts.throttleMs ?? 50;
|
this.throttleMs = opts.throttleMs ?? 50;
|
||||||
this.batchSize = opts.batchSize ?? 20;
|
this.batchSize = opts.batchSize ?? 20;
|
||||||
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
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}>} */
|
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
||||||
this._pending = new Map();
|
this._pending = new Map();
|
||||||
@@ -28,6 +30,7 @@ export class CommandQueue {
|
|||||||
this._sendFn = null;
|
this._sendFn = null;
|
||||||
this._totalSent = 0;
|
this._totalSent = 0;
|
||||||
this._totalCompleted = 0;
|
this._totalCompleted = 0;
|
||||||
|
this._cancelBuild = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +107,63 @@ export class CommandQueue {
|
|||||||
return { total: commands.length, succeeded, failed, results };
|
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 */
|
/** Process queued commands respecting rate limits */
|
||||||
async _processQueue() {
|
async _processQueue() {
|
||||||
if (this._processing) return;
|
if (this._processing) return;
|
||||||
|
|||||||
107
src/encryption.js
Normal file
107
src/encryption.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* In-memory ring buffer for Minecraft game events.
|
* 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 {
|
export class EventStore {
|
||||||
/** @param {number} capacity - Maximum events to retain */
|
/** @param {number} capacity - Maximum events to retain */
|
||||||
constructor(capacity = 100) {
|
constructor(capacity) {
|
||||||
this._capacity = capacity;
|
this._capacity = capacity ?? parseInt(process.env.EVENT_BUFFER_SIZE || '1000', 10);
|
||||||
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
||||||
this._events = [];
|
this._events = [];
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,31 @@ export class EventStore {
|
|||||||
.slice(-count);
|
.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 */
|
/** @returns {number} Total events currently stored */
|
||||||
get size() {
|
get size() {
|
||||||
return this._events.length;
|
return this._events.length;
|
||||||
|
|||||||
396
src/grabcraft.js
Normal file
396
src/grabcraft.js
Normal 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' },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
const TAG = 'MCP';
|
||||||
|
|
||||||
@@ -40,10 +43,10 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: 'minecraft-bridge',
|
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',
|
title: 'Minecraft Build',
|
||||||
description:
|
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({
|
inputSchema: z.object({
|
||||||
commands: z
|
commands: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.max(200)
|
.max(bedrock.commandQueue.maxBuildCommands)
|
||||||
.describe(
|
.describe(
|
||||||
'Array of build commands. E.g. ["setblock 10 64 10 stone", "fill 10 64 10 20 64 20 glass"]'
|
'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
|
// Validate: only allow build commands
|
||||||
const allowed = ['setblock', 'fill', 'clone', 'structure'];
|
const allowed = ['setblock', 'fill', 'clone', 'structure'];
|
||||||
const invalid = commands.filter((cmd) => {
|
const invalid = commands.filter((cmd) => {
|
||||||
@@ -161,12 +164,31 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
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(
|
.describe(
|
||||||
'Filter by event type. E.g. "PlayerMessage", "BlockChanged"'
|
'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 n = count ?? 20;
|
||||||
const events = type
|
let events;
|
||||||
? bedrock.events.getByType(type, n)
|
|
||||||
: bedrock.events.getRecent(n);
|
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) {
|
if (events.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -250,6 +288,7 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
const status = bedrock.getStatus();
|
const status = bedrock.getStatus();
|
||||||
const lines = [
|
const lines = [
|
||||||
`Connected: ${status.connected ? 'YES' : 'NO'}`,
|
`Connected: ${status.connected ? 'YES' : 'NO'}`,
|
||||||
|
`Encrypted: ${status.encrypted ? 'YES' : 'NO'}`,
|
||||||
`Player: ${status.playerName || 'unknown'}`,
|
`Player: ${status.playerName || 'unknown'}`,
|
||||||
`Connected since: ${status.connectedAt || 'N/A'}`,
|
`Connected since: ${status.connectedAt || 'N/A'}`,
|
||||||
`Subscriptions: ${status.subscriptions.join(', ') || 'none'}`,
|
`Subscriptions: ${status.subscriptions.join(', ') || 'none'}`,
|
||||||
@@ -258,6 +297,7 @@ export function startMcpServer(bedrock, port = 3002) {
|
|||||||
`Commands completed: ${status.totalCompleted}`,
|
`Commands completed: ${status.totalCompleted}`,
|
||||||
`Queue size: ${status.queueSize}`,
|
`Queue size: ${status.queueSize}`,
|
||||||
`In-flight: ${status.inFlight}`,
|
`In-flight: ${status.inFlight}`,
|
||||||
|
`Max build commands: ${bedrock.commandQueue.maxBuildCommands}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
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;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/utils.js
28
src/utils.js
@@ -48,6 +48,34 @@ export function createCommandMessage(commandLine) {
|
|||||||
return { id: requestId, message };
|
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).
|
* Strip Minecraft formatting codes (section sign + character).
|
||||||
* @param {string} text
|
* @param {string} text
|
||||||
|
|||||||
Reference in New Issue
Block a user