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

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

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

View File

@@ -1,42 +1,42 @@
name: Deploy to Docker
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
password: ${{ secrets.DEPLOY_PASSWORD }}
port: 22
script: |
set -e
APP_DIR="$HOME/mc-ai-bridge"
# First run: clone. Subsequent: pull.
if [ ! -d "$APP_DIR/.git" ]; then
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
else
cd "$APP_DIR"
git fetch origin main
git reset --hard origin/main
fi
cd "$APP_DIR"
# Build and deploy
docker compose down --remove-orphans || true
docker compose build --no-cache
docker compose up -d
# Verify container is running
sleep 5
docker compose ps
echo "--- Health check ---"
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"
name: Deploy to Docker
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
password: ${{ secrets.DEPLOY_PASSWORD }}
port: 22
script: |
set -e
APP_DIR="$HOME/mc-ai-bridge"
# First run: clone. Subsequent: pull.
if [ ! -d "$APP_DIR/.git" ]; then
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
else
cd "$APP_DIR"
git fetch origin main
git reset --hard origin/main
fi
cd "$APP_DIR"
# Build and deploy
docker compose down --remove-orphans || true
docker compose build --no-cache
docker compose up -d
# Verify container is running
sleep 5
docker compose ps
echo "--- Health check ---"
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"

8
.gitignore vendored
View File

@@ -1,4 +1,4 @@
node_modules/
.env
*.log
.DS_Store
node_modules/
.env
*.log
.DS_Store

View File

@@ -1,14 +1,14 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --production 2>/dev/null || npm install --production
COPY src/ ./src/
EXPOSE 3001 3002
USER node
CMD ["node", "src/index.js"]
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --production 2>/dev/null || npm install --production
COPY src/ ./src/
EXPOSE 3001 3002
USER node
CMD ["node", "src/index.js"]

View File

@@ -1,12 +1,12 @@
services:
mc-ai-bridge:
build: .
container_name: mc-ai-bridge
ports:
- "3001:3001" # Minecraft WebSocket
- "3002:3002" # MCP SSE transport
environment:
- WS_PORT=3001
- MCP_PORT=3002
- NODE_ENV=production
restart: unless-stopped
services:
mc-ai-bridge:
build: .
container_name: mc-ai-bridge
ports:
- "3001:3001" # Minecraft WebSocket
- "3002:3002" # MCP SSE transport
environment:
- WS_PORT=3001
- MCP_PORT=3002
- NODE_ENV=production
restart: unless-stopped

3104
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
{
"name": "mc-ai-bridge",
"version": "1.0.0",
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"express": "^4.21.2",
"ws": "^8.18.0",
"zod": "^3.25.0"
},
"engines": {
"node": ">=20.0.0"
},
"private": true
}
{
"name": "mc-ai-bridge",
"version": "1.0.0",
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"express": "^4.21.2",
"ws": "^8.18.0",
"zod": "^3.25.0"
},
"engines": {
"node": ">=20.0.0"
},
"private": true
}

View File

@@ -1,224 +1,382 @@
import { WebSocketServer } from 'ws';
import { createSubscribeMessage, createCommandMessage, sanitize, log, logError } from './utils.js';
import { EventStore } from './event-store.js';
import { CommandQueue } from './command-queue.js';
const TAG = 'BedrockWS';
/**
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
* Only one Minecraft client is supported at a time.
*/
export class BedrockWebSocket {
/**
* @param {object} opts
* @param {number} opts.port - WebSocket listen port (default 3001)
*/
constructor(opts = {}) {
this.port = opts.port ?? 3001;
this.events = new EventStore(100);
this.commandQueue = new CommandQueue();
/** @type {import('ws').WebSocket | null} */
this._ws = null;
this._wss = null;
this._connectedAt = null;
this._playerName = null;
this._subscriptions = new Set();
}
/** Start the WebSocket server */
start() {
this._wss = new WebSocketServer({ port: this.port });
this._wss.on('listening', () => {
log(TAG, `WebSocket server listening on port ${this.port}`);
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
});
this._wss.on('connection', (ws) => {
// Only allow one connection at a time
if (this._ws) {
log(TAG, 'Rejecting new connection - already have an active client');
ws.close(1000, 'Only one Minecraft client supported');
return;
}
this._ws = ws;
this._connectedAt = new Date();
log(TAG, 'Minecraft client connected!');
// Wire up command queue to send over this socket
this.commandQueue.setSendFunction((id, message) => {
if (this._ws && this._ws.readyState === 1) {
this._ws.send(message);
} else {
throw new Error('WebSocket not connected');
}
});
// Auto-subscribe to key events
this._autoSubscribe();
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw.toString());
this._handleMessage(data);
} catch (err) {
logError(TAG, 'Failed to parse message:', err.message);
}
});
ws.on('close', (code, reason) => {
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
this._ws = null;
this._connectedAt = null;
this._playerName = null;
this._subscriptions.clear();
this.commandQueue.setSendFunction(null);
});
ws.on('error', (err) => {
logError(TAG, 'WebSocket error:', err.message);
});
});
this._wss.on('error', (err) => {
logError(TAG, 'Server error:', err.message);
});
}
/** Subscribe to default event types */
_autoSubscribe() {
const defaultEvents = ['PlayerMessage'];
for (const eventName of defaultEvents) {
this.subscribe(eventName);
}
}
/**
* Subscribe to a Bedrock event type.
* @param {string} eventName
*/
subscribe(eventName) {
if (!this._ws || this._ws.readyState !== 1) {
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
return false;
}
if (this._subscriptions.has(eventName)) {
log(TAG, `Already subscribed to ${eventName}`);
return true;
}
this._ws.send(createSubscribeMessage(eventName));
this._subscriptions.add(eventName);
log(TAG, `Subscribed to ${eventName}`);
return true;
}
/**
* Handle an incoming WebSocket message from Bedrock.
* @param {object} data - Parsed JSON message
*/
_handleMessage(data) {
const purpose = data?.header?.messagePurpose;
if (purpose === 'commandResponse') {
// Response to a command we sent
const requestId = data.header.requestId;
this.commandQueue.handleResponse(requestId, data.body);
return;
}
if (purpose === 'event') {
const eventName = data.header.eventName;
const body = data.body || {};
// Filter bot's own messages to prevent echo loops
if (eventName === 'PlayerMessage') {
const sender = body.sender || '';
const message = sanitize(body.message || '');
const type = body.type || 'chat';
// Skip messages from external sources (commands, say, tell from server)
if (type !== 'chat' || sender === 'External' || sender === '') {
return;
}
// Track player name from first chat message
if (!this._playerName && sender) {
this._playerName = sender;
log(TAG, `Player identified: ${this._playerName}`);
}
this.events.push(eventName, {
sender,
message,
type,
});
log(TAG, `[Chat] <${sender}> ${message}`);
return;
}
// Store all other events
this.events.push(eventName, body);
}
}
/**
* Send a command to Minecraft.
* @param {string} commandLine - e.g. "give @p diamond 64"
* @returns {Promise<object>} Command response
*/
async sendCommand(commandLine) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const { id, message } = createCommandMessage(commandLine);
return this.commandQueue.enqueue(id, message);
}
/**
* Send a batch of commands (for building).
* @param {string[]} commandLines
* @returns {Promise<object>} Batch result
*/
async sendBatch(commandLines) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const commands = commandLines.map((line) => createCommandMessage(line));
return this.commandQueue.enqueueBatch(commands);
}
/** @returns {boolean} Whether a Minecraft client is connected */
isConnected() {
return this._ws !== null && this._ws.readyState === 1;
}
/** @returns {object} Status information */
getStatus() {
return {
connected: this.isConnected(),
playerName: this._playerName,
connectedAt: this._connectedAt?.toISOString() || null,
subscriptions: [...this._subscriptions],
eventCount: this.events.size,
...this.commandQueue.getStatus(),
};
}
/** Shut down the server */
stop() {
this.commandQueue.destroy();
if (this._ws) {
this._ws.close();
}
if (this._wss) {
this._wss.close();
}
log(TAG, 'Server stopped');
}
}
import { WebSocketServer } from 'ws';
import { createSubscribeMessage, createCommandMessage, createEnableEncryptionMessage, sanitize, log, logError } from './utils.js';
import { EventStore } from './event-store.js';
import { CommandQueue } from './command-queue.js';
import { ServerEncryption } from './encryption.js';
const TAG = 'BedrockWS';
/**
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
* Only one Minecraft client is supported at a time.
* Supports Bedrock's application-level encryption handshake.
*/
export class BedrockWebSocket {
/**
* @param {object} opts
* @param {number} opts.port - WebSocket listen port (default 3001)
*/
constructor(opts = {}) {
this.port = opts.port ?? 3001;
this.events = new EventStore();
this.commandQueue = new CommandQueue();
/** @type {import('ws').WebSocket | null} */
this._ws = null;
this._wss = null;
this._connectedAt = null;
this._playerName = null;
this._subscriptions = new Set();
// Encryption state
/** @type {ServerEncryption | null} */
this._encryption = null;
this._pendingEncryption = false;
this._encryptionRequestId = null;
}
/** Start the WebSocket server */
start() {
this._wss = new WebSocketServer({
port: this.port,
handleProtocols: (protocols) => {
// Accept Bedrock's encryption subprotocol if offered
if (protocols.has('com.microsoft.minecraft.wsencrypt')) {
return 'com.microsoft.minecraft.wsencrypt';
}
return false;
},
});
this._wss.on('listening', () => {
log(TAG, `WebSocket server listening on port ${this.port}`);
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
});
this._wss.on('connection', (ws) => {
// Only allow one connection at a time
if (this._ws) {
log(TAG, 'Rejecting new connection - already have an active client');
ws.close(1000, 'Only one Minecraft client supported');
return;
}
this._ws = ws;
this._connectedAt = new Date();
log(TAG, 'Minecraft client connected!');
// Start encryption handshake BEFORE wiring up command queue
this._beginEncryptionHandshake();
ws.on('message', (raw) => {
try {
let data;
if (this._encryption && this._encryption.enabled) {
// All messages after handshake are encrypted
const buf = typeof raw === 'string' ? Buffer.from(raw) : raw;
const plaintext = this._encryption.decrypt(buf);
data = JSON.parse(plaintext);
} else {
// Pre-encryption: plaintext JSON
data = JSON.parse(raw.toString());
}
this._handleMessage(data);
} catch (err) {
logError(TAG, 'Failed to parse message:', err.message);
}
});
ws.on('close', (code, reason) => {
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
this._ws = null;
this._connectedAt = null;
this._playerName = null;
this._subscriptions.clear();
this._encryption = null;
this._pendingEncryption = false;
this._encryptionRequestId = null;
this.commandQueue.setSendFunction(null);
});
ws.on('error', (err) => {
logError(TAG, 'WebSocket error:', err.message);
});
});
this._wss.on('error', (err) => {
logError(TAG, 'Server error:', err.message);
});
}
/** Initiate the Bedrock encryption handshake */
_beginEncryptionHandshake() {
this._encryption = new ServerEncryption();
this._pendingEncryption = true;
const { publicKey, salt } = this._encryption.getKeyExchangeParams();
const { id, message } = createEnableEncryptionMessage(publicKey, salt);
this._encryptionRequestId = id;
log(TAG, 'Sending enableencryption handshake...');
// Send plaintext — this is the last unencrypted message from server
if (this._ws && this._ws.readyState === 1) {
this._ws.send(message);
}
}
/** Called after encryption handshake completes to wire up normal operation */
_onEncryptionReady() {
log(TAG, 'Encryption active — wiring command queue and auto-subscribing');
// Now wire up command queue to send through encryption
this.commandQueue.setSendFunction((id, message) => {
if (this._ws && this._ws.readyState === 1) {
if (this._encryption && this._encryption.enabled) {
this._ws.send(this._encryption.encrypt(message));
} else {
this._ws.send(message);
}
} else {
throw new Error('WebSocket not connected');
}
});
// Auto-subscribe to key events
this._autoSubscribe();
}
/** Subscribe to default event types */
_autoSubscribe() {
const defaultEvents = ['PlayerMessage'];
for (const eventName of defaultEvents) {
this.subscribe(eventName);
}
}
/**
* Subscribe to a Bedrock event type.
* @param {string} eventName
*/
subscribe(eventName) {
if (!this._ws || this._ws.readyState !== 1) {
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
return false;
}
if (this._subscriptions.has(eventName)) {
log(TAG, `Already subscribed to ${eventName}`);
return true;
}
const msg = createSubscribeMessage(eventName);
if (this._encryption && this._encryption.enabled) {
this._ws.send(this._encryption.encrypt(msg));
} else {
this._ws.send(msg);
}
this._subscriptions.add(eventName);
log(TAG, `Subscribed to ${eventName}`);
return true;
}
/**
* Handle an incoming WebSocket message from Bedrock.
* @param {object} data - Parsed JSON message
*/
_handleMessage(data) {
const purpose = data?.header?.messagePurpose;
// Intercept encryption handshake response
if (this._pendingEncryption && purpose === 'commandResponse') {
const requestId = data.header.requestId;
if (requestId === this._encryptionRequestId) {
this._pendingEncryption = false;
this._encryptionRequestId = null;
const publicKey = data.body?.publicKey;
if (publicKey) {
try {
this._encryption.completeKeyExchange(publicKey);
this._onEncryptionReady();
} catch (err) {
logError(TAG, 'Encryption key exchange failed:', err.message);
// Fall back to unencrypted mode
this._encryption = null;
this._onEncryptionReady();
}
} else {
logError(TAG, 'No public key in encryption response — falling back to plaintext');
this._encryption = null;
this._onEncryptionReady();
}
return;
}
}
if (purpose === 'commandResponse') {
// Response to a command we sent
const requestId = data.header.requestId;
this.commandQueue.handleResponse(requestId, data.body);
return;
}
if (purpose === 'event') {
const eventName = data.header.eventName;
const body = data.body || {};
// Filter bot's own messages to prevent echo loops
if (eventName === 'PlayerMessage') {
const sender = body.sender || '';
const message = sanitize(body.message || '');
const type = body.type || 'chat';
// Skip messages from external sources (commands, say, tell from server)
if (type !== 'chat' || sender === 'External' || sender === '') {
return;
}
// Track player name from first chat message
if (!this._playerName && sender) {
this._playerName = sender;
log(TAG, `Player identified: ${this._playerName}`);
}
this.events.push(eventName, {
sender,
message,
type,
});
log(TAG, `[Chat] <${sender}> ${message}`);
return;
}
// Store all other events
this.events.push(eventName, body);
}
}
/**
* Send a command to Minecraft.
* @param {string} commandLine - e.g. "give @p diamond 64"
* @returns {Promise<object>} Command response
*/
async sendCommand(commandLine) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const { id, message } = createCommandMessage(commandLine);
return this.commandQueue.enqueue(id, message);
}
/**
* Send a batch of commands (for building).
* @param {string[]} commandLines
* @returns {Promise<object>} Batch result
*/
async sendBatch(commandLines) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const commands = commandLines.map((line) => createCommandMessage(line));
return this.commandQueue.enqueueBatch(commands);
}
/** @returns {boolean} Whether a Minecraft client is connected */
isConnected() {
return this._ws !== null && this._ws.readyState === 1;
}
/**
* Get the player's current position using /querytarget @s.
* @returns {Promise<{ x: number, y: number, z: number, rx: number, ry: number, dimension: number }>}
*/
async getPlayerPosition() {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const response = await this.sendCommand('querytarget @s');
const details = response?.details;
if (!details) {
throw new Error('No response from querytarget — is a player connected?');
}
// querytarget returns a JSON string in details field
try {
let parsed;
if (typeof details === 'string') {
// Response is a JSON array string like: [{"uniqueId":...,"position":{...},...}]
parsed = JSON.parse(details);
} else {
parsed = details;
}
const target = Array.isArray(parsed) ? parsed[0] : parsed;
if (!target || !target.position) {
throw new Error('Invalid querytarget response format');
}
return {
x: Math.floor(target.position.x),
y: Math.floor(target.position.y),
z: Math.floor(target.position.z),
rx: target.yRot ?? 0,
ry: target.xRot ?? 0,
dimension: target.dimension ?? 0,
};
} catch (err) {
if (err.message.includes('Invalid querytarget')) throw err;
throw new Error(`Failed to parse position data: ${err.message}`);
}
}
/**
* Test for a specific block at coordinates.
* @param {number} x
* @param {number} y
* @param {number} z
* @param {string} [blockId] - Optional block ID to test for
* @returns {Promise<object>} Test result
*/
async testForBlock(x, y, z, blockId) {
if (!this.isConnected()) {
throw new Error('Minecraft is not connected');
}
const cmd = blockId
? `testforblock ${x} ${y} ${z} ${blockId}`
: `testforblock ${x} ${y} ${z}`;
return this.sendCommand(cmd);
}
/** @returns {object} Status information */
getStatus() {
return {
connected: this.isConnected(),
encrypted: this._encryption?.enabled ?? false,
playerName: this._playerName,
connectedAt: this._connectedAt?.toISOString() || null,
subscriptions: [...this._subscriptions],
eventCount: this.events.size,
...this.commandQueue.getStatus(),
};
}
/** Shut down the server */
stop() {
this.commandQueue.destroy();
if (this._ws) {
this._ws.close();
}
if (this._wss) {
this._wss.close();
}
log(TAG, 'Server stopped');
}
}

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

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

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

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

View File

@@ -1,181 +1,241 @@
import { log, logError } from './utils.js';
const TAG = 'CommandQueue';
/**
* Rate-limited command dispatcher for Minecraft Bedrock.
* Bedrock has a hard limit of ~100 in-flight commands.
* We cap at 80 and throttle at 50ms between commands.
*/
export class CommandQueue {
/**
* @param {object} opts
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
*/
constructor(opts = {}) {
this.maxInFlight = opts.maxInFlight ?? 80;
this.throttleMs = opts.throttleMs ?? 50;
this.batchSize = opts.batchSize ?? 20;
this.batchDelayMs = opts.batchDelayMs ?? 200;
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
this._pending = new Map();
this._queue = [];
this._processing = false;
this._sendFn = null;
this._totalSent = 0;
this._totalCompleted = 0;
}
/**
* Set the function used to actually send a command over WebSocket.
* @param {(id: string, message: string) => void} fn
*/
setSendFunction(fn) {
this._sendFn = fn;
}
/**
* Called when a command response comes back from Bedrock.
* @param {string} requestId
* @param {object} response
*/
handleResponse(requestId, response) {
const entry = this._pending.get(requestId);
if (entry) {
clearTimeout(entry.timer);
this._pending.delete(requestId);
this._totalCompleted++;
entry.resolve(response);
}
}
/**
* Enqueue a single command for dispatch.
* @param {string} id - Request UUID
* @param {string} message - Serialized WS message
* @param {number} timeoutMs - Per-command timeout (default 10s)
* @returns {Promise<object>} Resolves with Bedrock response
*/
enqueue(id, message, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
this._queue.push({ id, message, resolve, reject, timeoutMs });
this._processQueue();
});
}
/**
* Enqueue a batch of commands (for building).
* Sends in groups of batchSize with batchDelayMs between groups.
* @param {Array<{id: string, message: string}>} commands
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
*/
async enqueueBatch(commands) {
const results = [];
let succeeded = 0;
let failed = 0;
for (let i = 0; i < commands.length; i += this.batchSize) {
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' });
}
}
// 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, results };
}
/** Process queued commands respecting rate limits */
async _processQueue() {
if (this._processing) return;
this._processing = true;
while (this._queue.length > 0) {
// Wait if at capacity
if (this._pending.size >= this.maxInFlight) {
await this._delay(this.throttleMs);
continue;
}
const item = this._queue.shift();
if (!item) break;
if (!this._sendFn) {
item.reject(new Error('No WebSocket connection'));
continue;
}
// Set up timeout
const timer = setTimeout(() => {
const entry = this._pending.get(item.id);
if (entry) {
this._pending.delete(item.id);
entry.reject(new Error('Command timed out'));
}
}, item.timeoutMs);
this._pending.set(item.id, {
resolve: item.resolve,
reject: item.reject,
timer,
});
try {
this._sendFn(item.id, item.message);
this._totalSent++;
} catch (err) {
clearTimeout(timer);
this._pending.delete(item.id);
item.reject(err);
}
// Throttle between sends
await this._delay(this.throttleMs);
}
this._processing = false;
}
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
getStatus() {
return {
queueSize: this._queue.length,
inFlight: this._pending.size,
totalSent: this._totalSent,
totalCompleted: this._totalCompleted,
};
}
_delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Clean up all pending timeouts */
destroy() {
for (const entry of this._pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error('Queue destroyed'));
}
this._pending.clear();
this._queue = [];
}
}
import { log, logError } from './utils.js';
const TAG = 'CommandQueue';
/**
* Rate-limited command dispatcher for Minecraft Bedrock.
* Bedrock has a hard limit of ~100 in-flight commands.
* We cap at 80 and throttle at 50ms between commands.
*/
export class CommandQueue {
/**
* @param {object} opts
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
* @param {number} opts.maxBuildCommands - Max commands per build (default from env or 5000)
*/
constructor(opts = {}) {
this.maxInFlight = opts.maxInFlight ?? 80;
this.throttleMs = opts.throttleMs ?? 50;
this.batchSize = opts.batchSize ?? 20;
this.batchDelayMs = opts.batchDelayMs ?? 200;
this.maxBuildCommands = opts.maxBuildCommands ?? parseInt(process.env.MAX_BUILD_COMMANDS || '5000', 10);
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
this._pending = new Map();
this._queue = [];
this._processing = false;
this._sendFn = null;
this._totalSent = 0;
this._totalCompleted = 0;
this._cancelBuild = false;
}
/**
* Set the function used to actually send a command over WebSocket.
* @param {(id: string, message: string) => void} fn
*/
setSendFunction(fn) {
this._sendFn = fn;
}
/**
* Called when a command response comes back from Bedrock.
* @param {string} requestId
* @param {object} response
*/
handleResponse(requestId, response) {
const entry = this._pending.get(requestId);
if (entry) {
clearTimeout(entry.timer);
this._pending.delete(requestId);
this._totalCompleted++;
entry.resolve(response);
}
}
/**
* Enqueue a single command for dispatch.
* @param {string} id - Request UUID
* @param {string} message - Serialized WS message
* @param {number} timeoutMs - Per-command timeout (default 10s)
* @returns {Promise<object>} Resolves with Bedrock response
*/
enqueue(id, message, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
this._queue.push({ id, message, resolve, reject, timeoutMs });
this._processQueue();
});
}
/**
* Enqueue a batch of commands (for building).
* Sends in groups of batchSize with batchDelayMs between groups.
* @param {Array<{id: string, message: string}>} commands
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
*/
async enqueueBatch(commands) {
const results = [];
let succeeded = 0;
let failed = 0;
for (let i = 0; i < commands.length; i += this.batchSize) {
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' });
}
}
// 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, results };
}
/**
* Enqueue a batch with progress reporting.
* Calls progressFn with status updates between layer batches.
* @param {Array<{id: string, message: string}>} commands
* @param {(progress: { completed: number, total: number, percent: number }) => void} [progressFn]
* @returns {Promise<{total: number, succeeded: number, failed: number, cancelled: boolean, results: Array}>}
*/
async enqueueBatchWithProgress(commands, progressFn) {
this._cancelBuild = false;
const results = [];
let succeeded = 0;
let failed = 0;
for (let i = 0; i < commands.length; i += this.batchSize) {
// Check cancellation
if (this._cancelBuild) {
log(TAG, `Build cancelled at ${i}/${commands.length} commands`);
return { total: commands.length, succeeded, failed, cancelled: true, results };
}
const batch = commands.slice(i, i + this.batchSize);
const batchResults = await Promise.allSettled(
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
);
for (const result of batchResults) {
if (result.status === 'fulfilled') {
succeeded++;
results.push(result.value);
} else {
failed++;
results.push({ error: result.reason?.message || 'unknown error' });
}
}
// Report progress
const completed = i + batch.length;
const percent = Math.round((completed / commands.length) * 100);
if (progressFn) {
progressFn({ completed, total: commands.length, percent });
}
// Delay between batches (except after the last one)
if (i + this.batchSize < commands.length) {
await this._delay(this.batchDelayMs);
}
}
return { total: commands.length, succeeded, failed, cancelled: false, results };
}
/** Cancel an in-progress build */
cancelBuild() {
this._cancelBuild = true;
}
/** Process queued commands respecting rate limits */
async _processQueue() {
if (this._processing) return;
this._processing = true;
while (this._queue.length > 0) {
// Wait if at capacity
if (this._pending.size >= this.maxInFlight) {
await this._delay(this.throttleMs);
continue;
}
const item = this._queue.shift();
if (!item) break;
if (!this._sendFn) {
item.reject(new Error('No WebSocket connection'));
continue;
}
// Set up timeout
const timer = setTimeout(() => {
const entry = this._pending.get(item.id);
if (entry) {
this._pending.delete(item.id);
entry.reject(new Error('Command timed out'));
}
}, item.timeoutMs);
this._pending.set(item.id, {
resolve: item.resolve,
reject: item.reject,
timer,
});
try {
this._sendFn(item.id, item.message);
this._totalSent++;
} catch (err) {
clearTimeout(timer);
this._pending.delete(item.id);
item.reject(err);
}
// Throttle between sends
await this._delay(this.throttleMs);
}
this._processing = false;
}
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
getStatus() {
return {
queueSize: this._queue.length,
inFlight: this._pending.size,
totalSent: this._totalSent,
totalCompleted: this._totalCompleted,
};
}
_delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Clean up all pending timeouts */
destroy() {
for (const entry of this._pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error('Queue destroyed'));
}
this._pending.clear();
this._queue = [];
}
}

107
src/encryption.js Normal file
View File

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

View File

@@ -1,61 +1,87 @@
/**
* In-memory ring buffer for Minecraft game events.
* Privacy-first: no disk persistence, lost on restart.
*/
export class EventStore {
/** @param {number} capacity - Maximum events to retain */
constructor(capacity = 100) {
this._capacity = capacity;
/** @type {Array<{type: string, timestamp: string, data: object}>} */
this._events = [];
}
/**
* Push a new event into the ring buffer.
* @param {string} type - Event type (e.g. "PlayerMessage")
* @param {object} data - Event payload
*/
push(type, data) {
this._events.push({
type,
timestamp: new Date().toISOString(),
data,
});
// Trim to capacity
if (this._events.length > this._capacity) {
this._events = this._events.slice(-this._capacity);
}
}
/**
* Get the most recent events.
* @param {number} count - Number of events to return
* @returns {Array}
*/
getRecent(count = 20) {
return this._events.slice(-count);
}
/**
* Get recent events filtered by type.
* @param {string} type - Event type to filter by
* @param {number} count - Max number to return
* @returns {Array}
*/
getByType(type, count = 20) {
return this._events
.filter((e) => e.type === type)
.slice(-count);
}
/** @returns {number} Total events currently stored */
get size() {
return this._events.length;
}
/** Clear all events */
clear() {
this._events = [];
}
}
/**
* In-memory ring buffer for Minecraft game events.
* Privacy-first: no disk persistence by default, lost on restart.
* Capacity configurable via EVENT_BUFFER_SIZE env var.
*/
export class EventStore {
/** @param {number} capacity - Maximum events to retain */
constructor(capacity) {
this._capacity = capacity ?? parseInt(process.env.EVENT_BUFFER_SIZE || '1000', 10);
/** @type {Array<{type: string, timestamp: string, data: object}>} */
this._events = [];
}
/**
* Push a new event into the ring buffer.
* @param {string} type - Event type (e.g. "PlayerMessage")
* @param {object} data - Event payload
*/
push(type, data) {
this._events.push({
type,
timestamp: new Date().toISOString(),
data,
});
// Trim to capacity
if (this._events.length > this._capacity) {
this._events = this._events.slice(-this._capacity);
}
}
/**
* Get the most recent events.
* @param {number} count - Number of events to return
* @returns {Array}
*/
getRecent(count = 20) {
return this._events.slice(-count);
}
/**
* Get recent events filtered by type.
* @param {string} type - Event type to filter by
* @param {number} count - Max number to return
* @returns {Array}
*/
getByType(type, count = 20) {
return this._events
.filter((e) => e.type === type)
.slice(-count);
}
/**
* Get recent events filtered by multiple types.
* @param {string[]} types - Event types to include
* @param {number} count - Max number to return
* @returns {Array}
*/
getByTypes(types, count = 20) {
const typeSet = new Set(types);
return this._events
.filter((e) => typeSet.has(e.type))
.slice(-count);
}
/**
* Get events since a given timestamp.
* @param {string} timestamp - ISO timestamp
* @param {number} count - Max number to return
* @returns {Array}
*/
getSince(timestamp, count = 100) {
return this._events
.filter((e) => e.timestamp >= timestamp)
.slice(-count);
}
/** @returns {number} Total events currently stored */
get size() {
return this._events.length;
}
/** Clear all events */
clear() {
this._events = [];
}
}

396
src/grabcraft.js Normal file
View File

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

View File

@@ -1,36 +1,36 @@
import { BedrockWebSocket } from './bedrock-ws.js';
import { startMcpServer } from './mcp-server.js';
import { log } from './utils.js';
const TAG = 'Main';
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
// Start the Bedrock WebSocket server
const bedrock = new BedrockWebSocket({ port: WS_PORT });
bedrock.start();
// Start the MCP server (Streamable HTTP transport)
startMcpServer(bedrock, MCP_PORT);
log(TAG, '');
log(TAG, '=== READY ===');
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${MCP_PORT}/mcp`);
log(TAG, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
log(TAG, '');
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
log(TAG, '');
// Graceful shutdown
function shutdown(signal) {
log(TAG, `${signal} received, shutting down...`);
bedrock.stop();
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
import { BedrockWebSocket } from './bedrock-ws.js';
import { startMcpServer } from './mcp-server.js';
import { log } from './utils.js';
const TAG = 'Main';
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
// Start the Bedrock WebSocket server
const bedrock = new BedrockWebSocket({ port: WS_PORT });
bedrock.start();
// Start the MCP server (Streamable HTTP transport)
startMcpServer(bedrock, MCP_PORT);
log(TAG, '');
log(TAG, '=== READY ===');
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${MCP_PORT}/mcp`);
log(TAG, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
log(TAG, '');
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
log(TAG, '');
// Graceful shutdown
function shutdown(signal) {
log(TAG, `${signal} received, shutting down...`);
bedrock.stop();
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +1,108 @@
import { randomUUID } from 'node:crypto';
/**
* Create a Bedrock WebSocket subscribe message envelope.
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
* @returns {string} JSON string ready to send over WS
*/
export function createSubscribeMessage(eventName) {
return JSON.stringify({
header: {
version: 1,
requestId: randomUUID(),
messageType: 'commandRequest',
messagePurpose: 'subscribe',
},
body: {
eventName,
},
});
}
/**
* Create a Bedrock WebSocket command message envelope.
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
* @returns {{ id: string, message: string }} request ID and JSON string
*/
export function createCommandMessage(commandLine) {
// Strip leading slash if present
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
const requestId = randomUUID();
const message = JSON.stringify({
header: {
version: 1,
requestId,
messageType: 'commandRequest',
messagePurpose: 'commandRequest',
},
body: {
version: 1,
commandLine: cmd,
origin: {
type: 'player',
},
},
});
return { id: requestId, message };
}
/**
* Strip Minecraft formatting codes (section sign + character).
* @param {string} text
* @returns {string}
*/
export function sanitize(text) {
if (!text) return '';
// Remove section sign formatting codes like §a, §l, §r etc.
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
}
/**
* Timestamped log helper.
* @param {string} tag - Module tag
* @param {...any} args - Log arguments
*/
export function log(tag, ...args) {
const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}]`, ...args);
}
/**
* Timestamped error log helper.
* @param {string} tag - Module tag
* @param {...any} args - Log arguments
*/
export function logError(tag, ...args) {
const ts = new Date().toISOString();
console.error(`[${ts}] [${tag}]`, ...args);
}
import { randomUUID } from 'node:crypto';
/**
* Create a Bedrock WebSocket subscribe message envelope.
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
* @returns {string} JSON string ready to send over WS
*/
export function createSubscribeMessage(eventName) {
return JSON.stringify({
header: {
version: 1,
requestId: randomUUID(),
messageType: 'commandRequest',
messagePurpose: 'subscribe',
},
body: {
eventName,
},
});
}
/**
* Create a Bedrock WebSocket command message envelope.
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
* @returns {{ id: string, message: string }} request ID and JSON string
*/
export function createCommandMessage(commandLine) {
// Strip leading slash if present
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
const requestId = randomUUID();
const message = JSON.stringify({
header: {
version: 1,
requestId,
messageType: 'commandRequest',
messagePurpose: 'commandRequest',
},
body: {
version: 1,
commandLine: cmd,
origin: {
type: 'player',
},
},
});
return { id: requestId, message };
}
/**
* Create a Bedrock WebSocket enableencryption command message.
* @param {string} publicKeyBase64 - Server's base64-encoded public key
* @param {string} saltBase64 - Base64-encoded 16-byte salt
* @returns {{ id: string, message: string }} request ID and JSON string
*/
export function createEnableEncryptionMessage(publicKeyBase64, saltBase64) {
const requestId = randomUUID();
const message = JSON.stringify({
header: {
version: 1,
requestId,
messageType: 'commandRequest',
messagePurpose: 'commandRequest',
},
body: {
version: 1,
commandLine: `enableencryption "${publicKeyBase64}" "${saltBase64}"`,
origin: {
type: 'player',
},
},
});
return { id: requestId, message };
}
/**
* Strip Minecraft formatting codes (section sign + character).
* @param {string} text
* @returns {string}
*/
export function sanitize(text) {
if (!text) return '';
// Remove section sign formatting codes like §a, §l, §r etc.
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
}
/**
* Timestamped log helper.
* @param {string} tag - Module tag
* @param {...any} args - Log arguments
*/
export function log(tag, ...args) {
const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}]`, ...args);
}
/**
* Timestamped error log helper.
* @param {string} tag - Module tag
* @param {...any} args - Log arguments
*/
export function logError(tag, ...args) {
const ts = new Date().toISOString();
console.error(`[${ts}] [${tag}]`, ...args);
}