feat: add GrabCraft blueprints, building helpers, and world state awareness
All checks were successful
Deploy to Docker / deploy (push) Successful in 12s
All checks were successful
Deploy to Docker / deploy (push) Successful in 12s
- Phase 1: Player position (querytarget @s) and testforblock tools - Phase 2: GrabCraft scraper with LRU cache, 372-block Java-to-Bedrock mapping, search and auto-build blueprint tools with dryRun support - Phase 3: Raise build limit to 5000 (MAX_BUILD_COMMANDS env), add progress notifications and build cancellation - Phase 4: Geometric shape builders (sphere, cylinder, dome, pyramid, wall, box) using fill commands for efficiency - Phase 5: Event buffer 100->1000 (EVENT_BUFFER_SIZE env), add getByTypes and getSince query methods - Phase 6: MCP resources for block ID reference and GrabCraft categories Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,42 @@
|
|||||||
name: Deploy to Docker
|
name: Deploy to Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy via SSH
|
- name: Deploy via SSH
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
password: ${{ secrets.DEPLOY_PASSWORD }}
|
password: ${{ secrets.DEPLOY_PASSWORD }}
|
||||||
port: 22
|
port: 22
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
APP_DIR="$HOME/mc-ai-bridge"
|
APP_DIR="$HOME/mc-ai-bridge"
|
||||||
|
|
||||||
# First run: clone. Subsequent: pull.
|
# First run: clone. Subsequent: pull.
|
||||||
if [ ! -d "$APP_DIR/.git" ]; then
|
if [ ! -d "$APP_DIR/.git" ]; then
|
||||||
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
|
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
|
||||||
else
|
else
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
|
|
||||||
# Build and deploy
|
# Build and deploy
|
||||||
docker compose down --remove-orphans || true
|
docker compose down --remove-orphans || true
|
||||||
docker compose build --no-cache
|
docker compose build --no-cache
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Verify container is running
|
# Verify container is running
|
||||||
sleep 5
|
sleep 5
|
||||||
docker compose ps
|
docker compose ps
|
||||||
echo "--- Health check ---"
|
echo "--- Health check ---"
|
||||||
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"
|
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
28
Dockerfile
28
Dockerfile
@@ -1,14 +1,14 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --production 2>/dev/null || npm install --production
|
RUN npm ci --production 2>/dev/null || npm install --production
|
||||||
|
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
EXPOSE 3001 3002
|
EXPOSE 3001 3002
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
mc-ai-bridge:
|
mc-ai-bridge:
|
||||||
build: .
|
build: .
|
||||||
container_name: mc-ai-bridge
|
container_name: mc-ai-bridge
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001" # Minecraft WebSocket
|
- "3001:3001" # Minecraft WebSocket
|
||||||
- "3002:3002" # MCP SSE transport
|
- "3002:3002" # MCP SSE transport
|
||||||
environment:
|
environment:
|
||||||
- WS_PORT=3001
|
- WS_PORT=3001
|
||||||
- MCP_PORT=3002
|
- MCP_PORT=3002
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
3104
package-lock.json
generated
3104
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "mc-ai-bridge",
|
"name": "mc-ai-bridge",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
|
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,224 +1,382 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { createSubscribeMessage, createCommandMessage, sanitize, log, logError } from './utils.js';
|
import { createSubscribeMessage, createCommandMessage, createEnableEncryptionMessage, sanitize, log, logError } from './utils.js';
|
||||||
import { EventStore } from './event-store.js';
|
import { EventStore } from './event-store.js';
|
||||||
import { CommandQueue } from './command-queue.js';
|
import { CommandQueue } from './command-queue.js';
|
||||||
|
import { ServerEncryption } from './encryption.js';
|
||||||
const TAG = 'BedrockWS';
|
|
||||||
|
const TAG = 'BedrockWS';
|
||||||
/**
|
|
||||||
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
/**
|
||||||
* Only one Minecraft client is supported at a time.
|
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
||||||
*/
|
* Only one Minecraft client is supported at a time.
|
||||||
export class BedrockWebSocket {
|
* Supports Bedrock's application-level encryption handshake.
|
||||||
/**
|
*/
|
||||||
* @param {object} opts
|
export class BedrockWebSocket {
|
||||||
* @param {number} opts.port - WebSocket listen port (default 3001)
|
/**
|
||||||
*/
|
* @param {object} opts
|
||||||
constructor(opts = {}) {
|
* @param {number} opts.port - WebSocket listen port (default 3001)
|
||||||
this.port = opts.port ?? 3001;
|
*/
|
||||||
this.events = new EventStore(100);
|
constructor(opts = {}) {
|
||||||
this.commandQueue = new CommandQueue();
|
this.port = opts.port ?? 3001;
|
||||||
|
this.events = new EventStore();
|
||||||
/** @type {import('ws').WebSocket | null} */
|
this.commandQueue = new CommandQueue();
|
||||||
this._ws = null;
|
|
||||||
this._wss = null;
|
/** @type {import('ws').WebSocket | null} */
|
||||||
this._connectedAt = null;
|
this._ws = null;
|
||||||
this._playerName = null;
|
this._wss = null;
|
||||||
this._subscriptions = new Set();
|
this._connectedAt = null;
|
||||||
}
|
this._playerName = null;
|
||||||
|
this._subscriptions = new Set();
|
||||||
/** Start the WebSocket server */
|
|
||||||
start() {
|
// Encryption state
|
||||||
this._wss = new WebSocketServer({ port: this.port });
|
/** @type {ServerEncryption | null} */
|
||||||
|
this._encryption = null;
|
||||||
this._wss.on('listening', () => {
|
this._pendingEncryption = false;
|
||||||
log(TAG, `WebSocket server listening on port ${this.port}`);
|
this._encryptionRequestId = null;
|
||||||
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
|
}
|
||||||
});
|
|
||||||
|
/** Start the WebSocket server */
|
||||||
this._wss.on('connection', (ws) => {
|
start() {
|
||||||
// Only allow one connection at a time
|
this._wss = new WebSocketServer({
|
||||||
if (this._ws) {
|
port: this.port,
|
||||||
log(TAG, 'Rejecting new connection - already have an active client');
|
handleProtocols: (protocols) => {
|
||||||
ws.close(1000, 'Only one Minecraft client supported');
|
// Accept Bedrock's encryption subprotocol if offered
|
||||||
return;
|
if (protocols.has('com.microsoft.minecraft.wsencrypt')) {
|
||||||
}
|
return 'com.microsoft.minecraft.wsencrypt';
|
||||||
|
}
|
||||||
this._ws = ws;
|
return false;
|
||||||
this._connectedAt = new Date();
|
},
|
||||||
log(TAG, 'Minecraft client connected!');
|
});
|
||||||
|
|
||||||
// Wire up command queue to send over this socket
|
this._wss.on('listening', () => {
|
||||||
this.commandQueue.setSendFunction((id, message) => {
|
log(TAG, `WebSocket server listening on port ${this.port}`);
|
||||||
if (this._ws && this._ws.readyState === 1) {
|
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
|
||||||
this._ws.send(message);
|
});
|
||||||
} else {
|
|
||||||
throw new Error('WebSocket not connected');
|
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');
|
||||||
// Auto-subscribe to key events
|
ws.close(1000, 'Only one Minecraft client supported');
|
||||||
this._autoSubscribe();
|
return;
|
||||||
|
}
|
||||||
ws.on('message', (raw) => {
|
|
||||||
try {
|
this._ws = ws;
|
||||||
const data = JSON.parse(raw.toString());
|
this._connectedAt = new Date();
|
||||||
this._handleMessage(data);
|
log(TAG, 'Minecraft client connected!');
|
||||||
} catch (err) {
|
|
||||||
logError(TAG, 'Failed to parse message:', err.message);
|
// Start encryption handshake BEFORE wiring up command queue
|
||||||
}
|
this._beginEncryptionHandshake();
|
||||||
});
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
ws.on('close', (code, reason) => {
|
try {
|
||||||
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
|
let data;
|
||||||
this._ws = null;
|
|
||||||
this._connectedAt = null;
|
if (this._encryption && this._encryption.enabled) {
|
||||||
this._playerName = null;
|
// All messages after handshake are encrypted
|
||||||
this._subscriptions.clear();
|
const buf = typeof raw === 'string' ? Buffer.from(raw) : raw;
|
||||||
this.commandQueue.setSendFunction(null);
|
const plaintext = this._encryption.decrypt(buf);
|
||||||
});
|
data = JSON.parse(plaintext);
|
||||||
|
} else {
|
||||||
ws.on('error', (err) => {
|
// Pre-encryption: plaintext JSON
|
||||||
logError(TAG, 'WebSocket error:', err.message);
|
data = JSON.parse(raw.toString());
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
this._handleMessage(data);
|
||||||
this._wss.on('error', (err) => {
|
} catch (err) {
|
||||||
logError(TAG, 'Server error:', err.message);
|
logError(TAG, 'Failed to parse message:', err.message);
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Subscribe to default event types */
|
ws.on('close', (code, reason) => {
|
||||||
_autoSubscribe() {
|
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
|
||||||
const defaultEvents = ['PlayerMessage'];
|
this._ws = null;
|
||||||
for (const eventName of defaultEvents) {
|
this._connectedAt = null;
|
||||||
this.subscribe(eventName);
|
this._playerName = null;
|
||||||
}
|
this._subscriptions.clear();
|
||||||
}
|
this._encryption = null;
|
||||||
|
this._pendingEncryption = false;
|
||||||
/**
|
this._encryptionRequestId = null;
|
||||||
* Subscribe to a Bedrock event type.
|
this.commandQueue.setSendFunction(null);
|
||||||
* @param {string} eventName
|
});
|
||||||
*/
|
|
||||||
subscribe(eventName) {
|
ws.on('error', (err) => {
|
||||||
if (!this._ws || this._ws.readyState !== 1) {
|
logError(TAG, 'WebSocket error:', err.message);
|
||||||
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
|
});
|
||||||
return false;
|
});
|
||||||
}
|
|
||||||
|
this._wss.on('error', (err) => {
|
||||||
if (this._subscriptions.has(eventName)) {
|
logError(TAG, 'Server error:', err.message);
|
||||||
log(TAG, `Already subscribed to ${eventName}`);
|
});
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
/** Initiate the Bedrock encryption handshake */
|
||||||
this._ws.send(createSubscribeMessage(eventName));
|
_beginEncryptionHandshake() {
|
||||||
this._subscriptions.add(eventName);
|
this._encryption = new ServerEncryption();
|
||||||
log(TAG, `Subscribed to ${eventName}`);
|
this._pendingEncryption = true;
|
||||||
return true;
|
|
||||||
}
|
const { publicKey, salt } = this._encryption.getKeyExchangeParams();
|
||||||
|
const { id, message } = createEnableEncryptionMessage(publicKey, salt);
|
||||||
/**
|
this._encryptionRequestId = id;
|
||||||
* Handle an incoming WebSocket message from Bedrock.
|
|
||||||
* @param {object} data - Parsed JSON message
|
log(TAG, 'Sending enableencryption handshake...');
|
||||||
*/
|
|
||||||
_handleMessage(data) {
|
// Send plaintext — this is the last unencrypted message from server
|
||||||
const purpose = data?.header?.messagePurpose;
|
if (this._ws && this._ws.readyState === 1) {
|
||||||
|
this._ws.send(message);
|
||||||
if (purpose === 'commandResponse') {
|
}
|
||||||
// Response to a command we sent
|
}
|
||||||
const requestId = data.header.requestId;
|
|
||||||
this.commandQueue.handleResponse(requestId, data.body);
|
/** Called after encryption handshake completes to wire up normal operation */
|
||||||
return;
|
_onEncryptionReady() {
|
||||||
}
|
log(TAG, 'Encryption active — wiring command queue and auto-subscribing');
|
||||||
|
|
||||||
if (purpose === 'event') {
|
// Now wire up command queue to send through encryption
|
||||||
const eventName = data.header.eventName;
|
this.commandQueue.setSendFunction((id, message) => {
|
||||||
const body = data.body || {};
|
if (this._ws && this._ws.readyState === 1) {
|
||||||
|
if (this._encryption && this._encryption.enabled) {
|
||||||
// Filter bot's own messages to prevent echo loops
|
this._ws.send(this._encryption.encrypt(message));
|
||||||
if (eventName === 'PlayerMessage') {
|
} else {
|
||||||
const sender = body.sender || '';
|
this._ws.send(message);
|
||||||
const message = sanitize(body.message || '');
|
}
|
||||||
const type = body.type || 'chat';
|
} else {
|
||||||
|
throw new Error('WebSocket not connected');
|
||||||
// Skip messages from external sources (commands, say, tell from server)
|
}
|
||||||
if (type !== 'chat' || sender === 'External' || sender === '') {
|
});
|
||||||
return;
|
|
||||||
}
|
// Auto-subscribe to key events
|
||||||
|
this._autoSubscribe();
|
||||||
// Track player name from first chat message
|
}
|
||||||
if (!this._playerName && sender) {
|
|
||||||
this._playerName = sender;
|
/** Subscribe to default event types */
|
||||||
log(TAG, `Player identified: ${this._playerName}`);
|
_autoSubscribe() {
|
||||||
}
|
const defaultEvents = ['PlayerMessage'];
|
||||||
|
for (const eventName of defaultEvents) {
|
||||||
this.events.push(eventName, {
|
this.subscribe(eventName);
|
||||||
sender,
|
}
|
||||||
message,
|
}
|
||||||
type,
|
|
||||||
});
|
/**
|
||||||
|
* Subscribe to a Bedrock event type.
|
||||||
log(TAG, `[Chat] <${sender}> ${message}`);
|
* @param {string} eventName
|
||||||
return;
|
*/
|
||||||
}
|
subscribe(eventName) {
|
||||||
|
if (!this._ws || this._ws.readyState !== 1) {
|
||||||
// Store all other events
|
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
|
||||||
this.events.push(eventName, body);
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (this._subscriptions.has(eventName)) {
|
||||||
/**
|
log(TAG, `Already subscribed to ${eventName}`);
|
||||||
* Send a command to Minecraft.
|
return true;
|
||||||
* @param {string} commandLine - e.g. "give @p diamond 64"
|
}
|
||||||
* @returns {Promise<object>} Command response
|
|
||||||
*/
|
const msg = createSubscribeMessage(eventName);
|
||||||
async sendCommand(commandLine) {
|
if (this._encryption && this._encryption.enabled) {
|
||||||
if (!this.isConnected()) {
|
this._ws.send(this._encryption.encrypt(msg));
|
||||||
throw new Error('Minecraft is not connected');
|
} else {
|
||||||
}
|
this._ws.send(msg);
|
||||||
const { id, message } = createCommandMessage(commandLine);
|
}
|
||||||
return this.commandQueue.enqueue(id, message);
|
this._subscriptions.add(eventName);
|
||||||
}
|
log(TAG, `Subscribed to ${eventName}`);
|
||||||
|
return true;
|
||||||
/**
|
}
|
||||||
* Send a batch of commands (for building).
|
|
||||||
* @param {string[]} commandLines
|
/**
|
||||||
* @returns {Promise<object>} Batch result
|
* Handle an incoming WebSocket message from Bedrock.
|
||||||
*/
|
* @param {object} data - Parsed JSON message
|
||||||
async sendBatch(commandLines) {
|
*/
|
||||||
if (!this.isConnected()) {
|
_handleMessage(data) {
|
||||||
throw new Error('Minecraft is not connected');
|
const purpose = data?.header?.messagePurpose;
|
||||||
}
|
|
||||||
const commands = commandLines.map((line) => createCommandMessage(line));
|
// Intercept encryption handshake response
|
||||||
return this.commandQueue.enqueueBatch(commands);
|
if (this._pendingEncryption && purpose === 'commandResponse') {
|
||||||
}
|
const requestId = data.header.requestId;
|
||||||
|
if (requestId === this._encryptionRequestId) {
|
||||||
/** @returns {boolean} Whether a Minecraft client is connected */
|
this._pendingEncryption = false;
|
||||||
isConnected() {
|
this._encryptionRequestId = null;
|
||||||
return this._ws !== null && this._ws.readyState === 1;
|
|
||||||
}
|
const publicKey = data.body?.publicKey;
|
||||||
|
if (publicKey) {
|
||||||
/** @returns {object} Status information */
|
try {
|
||||||
getStatus() {
|
this._encryption.completeKeyExchange(publicKey);
|
||||||
return {
|
this._onEncryptionReady();
|
||||||
connected: this.isConnected(),
|
} catch (err) {
|
||||||
playerName: this._playerName,
|
logError(TAG, 'Encryption key exchange failed:', err.message);
|
||||||
connectedAt: this._connectedAt?.toISOString() || null,
|
// Fall back to unencrypted mode
|
||||||
subscriptions: [...this._subscriptions],
|
this._encryption = null;
|
||||||
eventCount: this.events.size,
|
this._onEncryptionReady();
|
||||||
...this.commandQueue.getStatus(),
|
}
|
||||||
};
|
} else {
|
||||||
}
|
logError(TAG, 'No public key in encryption response — falling back to plaintext');
|
||||||
|
this._encryption = null;
|
||||||
/** Shut down the server */
|
this._onEncryptionReady();
|
||||||
stop() {
|
}
|
||||||
this.commandQueue.destroy();
|
return;
|
||||||
if (this._ws) {
|
}
|
||||||
this._ws.close();
|
}
|
||||||
}
|
|
||||||
if (this._wss) {
|
if (purpose === 'commandResponse') {
|
||||||
this._wss.close();
|
// Response to a command we sent
|
||||||
}
|
const requestId = data.header.requestId;
|
||||||
log(TAG, 'Server stopped');
|
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
692
src/block-map.js
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
import { log } from './utils.js';
|
||||||
|
|
||||||
|
const TAG = 'BlockMap';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from GrabCraft Java Edition block IDs to Bedrock Edition block IDs.
|
||||||
|
* GrabCraft uses format like "5:5" (blockId:dataValue) and English names.
|
||||||
|
* Bedrock uses string IDs like "planks" with data values.
|
||||||
|
*
|
||||||
|
* Format: { javaId: "numericId:data", bedrockId: "block_name", bedrockData: number }
|
||||||
|
*/
|
||||||
|
const BLOCK_MAP = new Map([
|
||||||
|
// ── Air ──
|
||||||
|
['0', { bedrock: 'air', data: 0, name: 'Air' }],
|
||||||
|
|
||||||
|
// ── Stone variants ──
|
||||||
|
['1', { bedrock: 'stone', data: 0, name: 'Stone' }],
|
||||||
|
['1:1', { bedrock: 'stone', data: 1, name: 'Granite' }],
|
||||||
|
['1:2', { bedrock: 'stone', data: 2, name: 'Polished Granite' }],
|
||||||
|
['1:3', { bedrock: 'stone', data: 3, name: 'Diorite' }],
|
||||||
|
['1:4', { bedrock: 'stone', data: 4, name: 'Polished Diorite' }],
|
||||||
|
['1:5', { bedrock: 'stone', data: 5, name: 'Andesite' }],
|
||||||
|
['1:6', { bedrock: 'stone', data: 6, name: 'Polished Andesite' }],
|
||||||
|
|
||||||
|
// ── Grass & Dirt ──
|
||||||
|
['2', { bedrock: 'grass_block', data: 0, name: 'Grass Block' }],
|
||||||
|
['3', { bedrock: 'dirt', data: 0, name: 'Dirt' }],
|
||||||
|
['3:1', { bedrock: 'dirt', data: 1, name: 'Coarse Dirt' }],
|
||||||
|
['3:2', { bedrock: 'podzol', data: 0, name: 'Podzol' }],
|
||||||
|
|
||||||
|
// ── Cobblestone ──
|
||||||
|
['4', { bedrock: 'cobblestone', data: 0, name: 'Cobblestone' }],
|
||||||
|
|
||||||
|
// ── Planks ──
|
||||||
|
['5', { bedrock: 'planks', data: 0, name: 'Oak Planks' }],
|
||||||
|
['5:1', { bedrock: 'planks', data: 1, name: 'Spruce Planks' }],
|
||||||
|
['5:2', { bedrock: 'planks', data: 2, name: 'Birch Planks' }],
|
||||||
|
['5:3', { bedrock: 'planks', data: 3, name: 'Jungle Planks' }],
|
||||||
|
['5:4', { bedrock: 'planks', data: 4, name: 'Acacia Planks' }],
|
||||||
|
['5:5', { bedrock: 'planks', data: 5, name: 'Dark Oak Planks' }],
|
||||||
|
|
||||||
|
// ── Saplings ──
|
||||||
|
['6', { bedrock: 'sapling', data: 0, name: 'Oak Sapling' }],
|
||||||
|
['6:1', { bedrock: 'sapling', data: 1, name: 'Spruce Sapling' }],
|
||||||
|
['6:2', { bedrock: 'sapling', data: 2, name: 'Birch Sapling' }],
|
||||||
|
['6:3', { bedrock: 'sapling', data: 3, name: 'Jungle Sapling' }],
|
||||||
|
['6:4', { bedrock: 'sapling', data: 4, name: 'Acacia Sapling' }],
|
||||||
|
['6:5', { bedrock: 'sapling', data: 5, name: 'Dark Oak Sapling' }],
|
||||||
|
|
||||||
|
// ── Bedrock ──
|
||||||
|
['7', { bedrock: 'bedrock', data: 0, name: 'Bedrock' }],
|
||||||
|
|
||||||
|
// ── Water & Lava ──
|
||||||
|
['8', { bedrock: 'water', data: 0, name: 'Water' }],
|
||||||
|
['9', { bedrock: 'water', data: 0, name: 'Water (stationary)' }],
|
||||||
|
['10', { bedrock: 'lava', data: 0, name: 'Lava' }],
|
||||||
|
['11', { bedrock: 'lava', data: 0, name: 'Lava (stationary)' }],
|
||||||
|
|
||||||
|
// ── Sand & Gravel ──
|
||||||
|
['12', { bedrock: 'sand', data: 0, name: 'Sand' }],
|
||||||
|
['12:1', { bedrock: 'sand', data: 1, name: 'Red Sand' }],
|
||||||
|
['13', { bedrock: 'gravel', data: 0, name: 'Gravel' }],
|
||||||
|
|
||||||
|
// ── Ores ──
|
||||||
|
['14', { bedrock: 'gold_ore', data: 0, name: 'Gold Ore' }],
|
||||||
|
['15', { bedrock: 'iron_ore', data: 0, name: 'Iron Ore' }],
|
||||||
|
['16', { bedrock: 'coal_ore', data: 0, name: 'Coal Ore' }],
|
||||||
|
|
||||||
|
// ── Logs ──
|
||||||
|
['17', { bedrock: 'log', data: 0, name: 'Oak Log' }],
|
||||||
|
['17:1', { bedrock: 'log', data: 1, name: 'Spruce Log' }],
|
||||||
|
['17:2', { bedrock: 'log', data: 2, name: 'Birch Log' }],
|
||||||
|
['17:3', { bedrock: 'log', data: 3, name: 'Jungle Log' }],
|
||||||
|
['162', { bedrock: 'log2', data: 0, name: 'Acacia Log' }],
|
||||||
|
['162:1', { bedrock: 'log2', data: 1, name: 'Dark Oak Log' }],
|
||||||
|
|
||||||
|
// ── Leaves ──
|
||||||
|
['18', { bedrock: 'leaves', data: 0, name: 'Oak Leaves' }],
|
||||||
|
['18:1', { bedrock: 'leaves', data: 1, name: 'Spruce Leaves' }],
|
||||||
|
['18:2', { bedrock: 'leaves', data: 2, name: 'Birch Leaves' }],
|
||||||
|
['18:3', { bedrock: 'leaves', data: 3, name: 'Jungle Leaves' }],
|
||||||
|
['161', { bedrock: 'leaves2', data: 0, name: 'Acacia Leaves' }],
|
||||||
|
['161:1', { bedrock: 'leaves2', data: 1, name: 'Dark Oak Leaves' }],
|
||||||
|
|
||||||
|
// ── Sponge ──
|
||||||
|
['19', { bedrock: 'sponge', data: 0, name: 'Sponge' }],
|
||||||
|
['19:1', { bedrock: 'sponge', data: 1, name: 'Wet Sponge' }],
|
||||||
|
|
||||||
|
// ── Glass ──
|
||||||
|
['20', { bedrock: 'glass', data: 0, name: 'Glass' }],
|
||||||
|
['102', { bedrock: 'glass_pane', data: 0, name: 'Glass Pane' }],
|
||||||
|
|
||||||
|
// ── Lapis ──
|
||||||
|
['21', { bedrock: 'lapis_ore', data: 0, name: 'Lapis Lazuli Ore' }],
|
||||||
|
['22', { bedrock: 'lapis_block', data: 0, name: 'Lapis Lazuli Block' }],
|
||||||
|
|
||||||
|
// ── Dispenser & Noteblock ──
|
||||||
|
['23', { bedrock: 'dispenser', data: 0, name: 'Dispenser' }],
|
||||||
|
['25', { bedrock: 'noteblock', data: 0, name: 'Note Block' }],
|
||||||
|
|
||||||
|
// ── Sandstone ──
|
||||||
|
['24', { bedrock: 'sandstone', data: 0, name: 'Sandstone' }],
|
||||||
|
['24:1', { bedrock: 'sandstone', data: 1, name: 'Chiseled Sandstone' }],
|
||||||
|
['24:2', { bedrock: 'sandstone', data: 2, name: 'Smooth Sandstone' }],
|
||||||
|
|
||||||
|
// ── Wool ──
|
||||||
|
['35', { bedrock: 'wool', data: 0, name: 'White Wool' }],
|
||||||
|
['35:1', { bedrock: 'wool', data: 1, name: 'Orange Wool' }],
|
||||||
|
['35:2', { bedrock: 'wool', data: 2, name: 'Magenta Wool' }],
|
||||||
|
['35:3', { bedrock: 'wool', data: 3, name: 'Light Blue Wool' }],
|
||||||
|
['35:4', { bedrock: 'wool', data: 4, name: 'Yellow Wool' }],
|
||||||
|
['35:5', { bedrock: 'wool', data: 5, name: 'Lime Wool' }],
|
||||||
|
['35:6', { bedrock: 'wool', data: 6, name: 'Pink Wool' }],
|
||||||
|
['35:7', { bedrock: 'wool', data: 7, name: 'Gray Wool' }],
|
||||||
|
['35:8', { bedrock: 'wool', data: 8, name: 'Light Gray Wool' }],
|
||||||
|
['35:9', { bedrock: 'wool', data: 9, name: 'Cyan Wool' }],
|
||||||
|
['35:10', { bedrock: 'wool', data: 10, name: 'Purple Wool' }],
|
||||||
|
['35:11', { bedrock: 'wool', data: 11, name: 'Blue Wool' }],
|
||||||
|
['35:12', { bedrock: 'wool', data: 12, name: 'Brown Wool' }],
|
||||||
|
['35:13', { bedrock: 'wool', data: 13, name: 'Green Wool' }],
|
||||||
|
['35:14', { bedrock: 'wool', data: 14, name: 'Red Wool' }],
|
||||||
|
['35:15', { bedrock: 'wool', data: 15, name: 'Black Wool' }],
|
||||||
|
|
||||||
|
// ── Gold & Iron blocks ──
|
||||||
|
['41', { bedrock: 'gold_block', data: 0, name: 'Block of Gold' }],
|
||||||
|
['42', { bedrock: 'iron_block', data: 0, name: 'Block of Iron' }],
|
||||||
|
|
||||||
|
// ── Slabs ──
|
||||||
|
['44', { bedrock: 'stone_block_slab', data: 0, name: 'Stone Slab' }],
|
||||||
|
['44:1', { bedrock: 'stone_block_slab', data: 1, name: 'Sandstone Slab' }],
|
||||||
|
['44:3', { bedrock: 'stone_block_slab', data: 3, name: 'Cobblestone Slab' }],
|
||||||
|
['44:4', { bedrock: 'stone_block_slab', data: 4, name: 'Brick Slab' }],
|
||||||
|
['44:5', { bedrock: 'stone_block_slab', data: 5, name: 'Stone Brick Slab' }],
|
||||||
|
['126', { bedrock: 'wooden_slab', data: 0, name: 'Oak Slab' }],
|
||||||
|
['126:1', { bedrock: 'wooden_slab', data: 1, name: 'Spruce Slab' }],
|
||||||
|
['126:2', { bedrock: 'wooden_slab', data: 2, name: 'Birch Slab' }],
|
||||||
|
['126:3', { bedrock: 'wooden_slab', data: 3, name: 'Jungle Slab' }],
|
||||||
|
['126:4', { bedrock: 'wooden_slab', data: 4, name: 'Acacia Slab' }],
|
||||||
|
['126:5', { bedrock: 'wooden_slab', data: 5, name: 'Dark Oak Slab' }],
|
||||||
|
|
||||||
|
// ── Bricks ──
|
||||||
|
['45', { bedrock: 'brick_block', data: 0, name: 'Bricks' }],
|
||||||
|
['98', { bedrock: 'stonebrick', data: 0, name: 'Stone Bricks' }],
|
||||||
|
['98:1', { bedrock: 'stonebrick', data: 1, name: 'Mossy Stone Bricks' }],
|
||||||
|
['98:2', { bedrock: 'stonebrick', data: 2, name: 'Cracked Stone Bricks' }],
|
||||||
|
['98:3', { bedrock: 'stonebrick', data: 3, name: 'Chiseled Stone Bricks' }],
|
||||||
|
['112', { bedrock: 'nether_brick', data: 0, name: 'Nether Bricks' }],
|
||||||
|
|
||||||
|
// ── TNT ──
|
||||||
|
['46', { bedrock: 'tnt', data: 0, name: 'TNT' }],
|
||||||
|
|
||||||
|
// ── Bookshelf ──
|
||||||
|
['47', { bedrock: 'bookshelf', data: 0, name: 'Bookshelf' }],
|
||||||
|
|
||||||
|
// ── Mossy Cobblestone ──
|
||||||
|
['48', { bedrock: 'mossy_cobblestone', data: 0, name: 'Mossy Cobblestone' }],
|
||||||
|
|
||||||
|
// ── Obsidian ──
|
||||||
|
['49', { bedrock: 'obsidian', data: 0, name: 'Obsidian' }],
|
||||||
|
|
||||||
|
// ── Torches ──
|
||||||
|
['50', { bedrock: 'torch', data: 0, name: 'Torch' }],
|
||||||
|
|
||||||
|
// ── Stairs ──
|
||||||
|
['53', { bedrock: 'oak_stairs', data: 0, name: 'Oak Stairs' }],
|
||||||
|
['67', { bedrock: 'stone_stairs', data: 0, name: 'Cobblestone Stairs' }],
|
||||||
|
['108', { bedrock: 'brick_stairs', data: 0, name: 'Brick Stairs' }],
|
||||||
|
['109', { bedrock: 'stone_brick_stairs', data: 0, name: 'Stone Brick Stairs' }],
|
||||||
|
['114', { bedrock: 'nether_brick_stairs', data: 0, name: 'Nether Brick Stairs' }],
|
||||||
|
['128', { bedrock: 'sandstone_stairs', data: 0, name: 'Sandstone Stairs' }],
|
||||||
|
['134', { bedrock: 'spruce_stairs', data: 0, name: 'Spruce Stairs' }],
|
||||||
|
['135', { bedrock: 'birch_stairs', data: 0, name: 'Birch Stairs' }],
|
||||||
|
['136', { bedrock: 'jungle_stairs', data: 0, name: 'Jungle Stairs' }],
|
||||||
|
['163', { bedrock: 'acacia_stairs', data: 0, name: 'Acacia Stairs' }],
|
||||||
|
['164', { bedrock: 'dark_oak_stairs', data: 0, name: 'Dark Oak Stairs' }],
|
||||||
|
['156', { bedrock: 'quartz_stairs', data: 0, name: 'Quartz Stairs' }],
|
||||||
|
|
||||||
|
// ── Chest ──
|
||||||
|
['54', { bedrock: 'chest', data: 0, name: 'Chest' }],
|
||||||
|
|
||||||
|
// ── Diamond ──
|
||||||
|
['56', { bedrock: 'diamond_ore', data: 0, name: 'Diamond Ore' }],
|
||||||
|
['57', { bedrock: 'diamond_block', data: 0, name: 'Block of Diamond' }],
|
||||||
|
|
||||||
|
// ── Crafting Table ──
|
||||||
|
['58', { bedrock: 'crafting_table', data: 0, name: 'Crafting Table' }],
|
||||||
|
|
||||||
|
// ── Furnace ──
|
||||||
|
['61', { bedrock: 'furnace', data: 0, name: 'Furnace' }],
|
||||||
|
['62', { bedrock: 'lit_furnace', data: 0, name: 'Burning Furnace' }],
|
||||||
|
|
||||||
|
// ── Doors ──
|
||||||
|
['64', { bedrock: 'wooden_door', data: 0, name: 'Oak Door' }],
|
||||||
|
['71', { bedrock: 'iron_door', data: 0, name: 'Iron Door' }],
|
||||||
|
['193', { bedrock: 'spruce_door', data: 0, name: 'Spruce Door' }],
|
||||||
|
['194', { bedrock: 'birch_door', data: 0, name: 'Birch Door' }],
|
||||||
|
['195', { bedrock: 'jungle_door', data: 0, name: 'Jungle Door' }],
|
||||||
|
['196', { bedrock: 'acacia_door', data: 0, name: 'Acacia Door' }],
|
||||||
|
['197', { bedrock: 'dark_oak_door', data: 0, name: 'Dark Oak Door' }],
|
||||||
|
|
||||||
|
// ── Ladders ──
|
||||||
|
['65', { bedrock: 'ladder', data: 0, name: 'Ladder' }],
|
||||||
|
|
||||||
|
// ── Rails ──
|
||||||
|
['66', { bedrock: 'rail', data: 0, name: 'Rail' }],
|
||||||
|
['27', { bedrock: 'golden_rail', data: 0, name: 'Powered Rail' }],
|
||||||
|
['28', { bedrock: 'detector_rail', data: 0, name: 'Detector Rail' }],
|
||||||
|
['157', { bedrock: 'activator_rail', data: 0, name: 'Activator Rail' }],
|
||||||
|
|
||||||
|
// ── Snow & Ice ──
|
||||||
|
['78', { bedrock: 'snow_layer', data: 0, name: 'Snow Layer' }],
|
||||||
|
['79', { bedrock: 'ice', data: 0, name: 'Ice' }],
|
||||||
|
['80', { bedrock: 'snow', data: 0, name: 'Snow Block' }],
|
||||||
|
['174', { bedrock: 'packed_ice', data: 0, name: 'Packed Ice' }],
|
||||||
|
|
||||||
|
// ── Cactus ──
|
||||||
|
['81', { bedrock: 'cactus', data: 0, name: 'Cactus' }],
|
||||||
|
|
||||||
|
// ── Clay ──
|
||||||
|
['82', { bedrock: 'clay', data: 0, name: 'Clay' }],
|
||||||
|
|
||||||
|
// ── Jukebox ──
|
||||||
|
['84', { bedrock: 'jukebox', data: 0, name: 'Jukebox' }],
|
||||||
|
|
||||||
|
// ── Fences ──
|
||||||
|
['85', { bedrock: 'fence', data: 0, name: 'Oak Fence' }],
|
||||||
|
['113', { bedrock: 'nether_brick_fence', data: 0, name: 'Nether Brick Fence' }],
|
||||||
|
['188', { bedrock: 'fence', data: 1, name: 'Spruce Fence' }],
|
||||||
|
['189', { bedrock: 'fence', data: 2, name: 'Birch Fence' }],
|
||||||
|
['190', { bedrock: 'fence', data: 3, name: 'Jungle Fence' }],
|
||||||
|
['191', { bedrock: 'fence', data: 4, name: 'Acacia Fence' }],
|
||||||
|
['192', { bedrock: 'fence', data: 5, name: 'Dark Oak Fence' }],
|
||||||
|
|
||||||
|
// ── Fence Gates ──
|
||||||
|
['107', { bedrock: 'fence_gate', data: 0, name: 'Oak Fence Gate' }],
|
||||||
|
['183', { bedrock: 'spruce_fence_gate', data: 0, name: 'Spruce Fence Gate' }],
|
||||||
|
['184', { bedrock: 'birch_fence_gate', data: 0, name: 'Birch Fence Gate' }],
|
||||||
|
['185', { bedrock: 'jungle_fence_gate', data: 0, name: 'Jungle Fence Gate' }],
|
||||||
|
['186', { bedrock: 'acacia_fence_gate', data: 0, name: 'Acacia Fence Gate' }],
|
||||||
|
['187', { bedrock: 'dark_oak_fence_gate', data: 0, name: 'Dark Oak Fence Gate' }],
|
||||||
|
|
||||||
|
// ── Pumpkin & Melon ──
|
||||||
|
['86', { bedrock: 'pumpkin', data: 0, name: 'Pumpkin' }],
|
||||||
|
['91', { bedrock: 'lit_pumpkin', data: 0, name: 'Jack o\'Lantern' }],
|
||||||
|
['103', { bedrock: 'melon_block', data: 0, name: 'Melon Block' }],
|
||||||
|
|
||||||
|
// ── Netherrack & Soul Sand ──
|
||||||
|
['87', { bedrock: 'netherrack', data: 0, name: 'Netherrack' }],
|
||||||
|
['88', { bedrock: 'soul_sand', data: 0, name: 'Soul Sand' }],
|
||||||
|
|
||||||
|
// ── Glowstone ──
|
||||||
|
['89', { bedrock: 'glowstone', data: 0, name: 'Glowstone' }],
|
||||||
|
|
||||||
|
// ── Stained Glass ──
|
||||||
|
['95', { bedrock: 'stained_glass', data: 0, name: 'White Stained Glass' }],
|
||||||
|
['95:1', { bedrock: 'stained_glass', data: 1, name: 'Orange Stained Glass' }],
|
||||||
|
['95:2', { bedrock: 'stained_glass', data: 2, name: 'Magenta Stained Glass' }],
|
||||||
|
['95:3', { bedrock: 'stained_glass', data: 3, name: 'Light Blue Stained Glass' }],
|
||||||
|
['95:4', { bedrock: 'stained_glass', data: 4, name: 'Yellow Stained Glass' }],
|
||||||
|
['95:5', { bedrock: 'stained_glass', data: 5, name: 'Lime Stained Glass' }],
|
||||||
|
['95:6', { bedrock: 'stained_glass', data: 6, name: 'Pink Stained Glass' }],
|
||||||
|
['95:7', { bedrock: 'stained_glass', data: 7, name: 'Gray Stained Glass' }],
|
||||||
|
['95:8', { bedrock: 'stained_glass', data: 8, name: 'Light Gray Stained Glass' }],
|
||||||
|
['95:9', { bedrock: 'stained_glass', data: 9, name: 'Cyan Stained Glass' }],
|
||||||
|
['95:10', { bedrock: 'stained_glass', data: 10, name: 'Purple Stained Glass' }],
|
||||||
|
['95:11', { bedrock: 'stained_glass', data: 11, name: 'Blue Stained Glass' }],
|
||||||
|
['95:12', { bedrock: 'stained_glass', data: 12, name: 'Brown Stained Glass' }],
|
||||||
|
['95:13', { bedrock: 'stained_glass', data: 13, name: 'Green Stained Glass' }],
|
||||||
|
['95:14', { bedrock: 'stained_glass', data: 14, name: 'Red Stained Glass' }],
|
||||||
|
['95:15', { bedrock: 'stained_glass', data: 15, name: 'Black Stained Glass' }],
|
||||||
|
|
||||||
|
// ── Stained Glass Panes ──
|
||||||
|
['160', { bedrock: 'stained_glass_pane', data: 0, name: 'White Stained Glass Pane' }],
|
||||||
|
['160:1', { bedrock: 'stained_glass_pane', data: 1, name: 'Orange Stained Glass Pane' }],
|
||||||
|
['160:2', { bedrock: 'stained_glass_pane', data: 2, name: 'Magenta Stained Glass Pane' }],
|
||||||
|
['160:3', { bedrock: 'stained_glass_pane', data: 3, name: 'Light Blue Stained Glass Pane' }],
|
||||||
|
['160:4', { bedrock: 'stained_glass_pane', data: 4, name: 'Yellow Stained Glass Pane' }],
|
||||||
|
['160:5', { bedrock: 'stained_glass_pane', data: 5, name: 'Lime Stained Glass Pane' }],
|
||||||
|
['160:6', { bedrock: 'stained_glass_pane', data: 6, name: 'Pink Stained Glass Pane' }],
|
||||||
|
['160:7', { bedrock: 'stained_glass_pane', data: 7, name: 'Gray Stained Glass Pane' }],
|
||||||
|
['160:8', { bedrock: 'stained_glass_pane', data: 8, name: 'Light Gray Stained Glass Pane' }],
|
||||||
|
['160:9', { bedrock: 'stained_glass_pane', data: 9, name: 'Cyan Stained Glass Pane' }],
|
||||||
|
['160:10', { bedrock: 'stained_glass_pane', data: 10, name: 'Purple Stained Glass Pane' }],
|
||||||
|
['160:11', { bedrock: 'stained_glass_pane', data: 11, name: 'Blue Stained Glass Pane' }],
|
||||||
|
['160:12', { bedrock: 'stained_glass_pane', data: 12, name: 'Brown Stained Glass Pane' }],
|
||||||
|
['160:13', { bedrock: 'stained_glass_pane', data: 13, name: 'Green Stained Glass Pane' }],
|
||||||
|
['160:14', { bedrock: 'stained_glass_pane', data: 14, name: 'Red Stained Glass Pane' }],
|
||||||
|
['160:15', { bedrock: 'stained_glass_pane', data: 15, name: 'Black Stained Glass Pane' }],
|
||||||
|
|
||||||
|
// ── Iron Bars ──
|
||||||
|
['101', { bedrock: 'iron_bars', data: 0, name: 'Iron Bars' }],
|
||||||
|
|
||||||
|
// ── Quartz ──
|
||||||
|
['155', { bedrock: 'quartz_block', data: 0, name: 'Quartz Block' }],
|
||||||
|
['155:1', { bedrock: 'quartz_block', data: 1, name: 'Chiseled Quartz' }],
|
||||||
|
['155:2', { bedrock: 'quartz_block', data: 2, name: 'Pillar Quartz' }],
|
||||||
|
|
||||||
|
// ── Terracotta (Hardened Clay) ──
|
||||||
|
['159', { bedrock: 'stained_hardened_clay', data: 0, name: 'White Terracotta' }],
|
||||||
|
['159:1', { bedrock: 'stained_hardened_clay', data: 1, name: 'Orange Terracotta' }],
|
||||||
|
['159:2', { bedrock: 'stained_hardened_clay', data: 2, name: 'Magenta Terracotta' }],
|
||||||
|
['159:3', { bedrock: 'stained_hardened_clay', data: 3, name: 'Light Blue Terracotta' }],
|
||||||
|
['159:4', { bedrock: 'stained_hardened_clay', data: 4, name: 'Yellow Terracotta' }],
|
||||||
|
['159:5', { bedrock: 'stained_hardened_clay', data: 5, name: 'Lime Terracotta' }],
|
||||||
|
['159:6', { bedrock: 'stained_hardened_clay', data: 6, name: 'Pink Terracotta' }],
|
||||||
|
['159:7', { bedrock: 'stained_hardened_clay', data: 7, name: 'Gray Terracotta' }],
|
||||||
|
['159:8', { bedrock: 'stained_hardened_clay', data: 8, name: 'Light Gray Terracotta' }],
|
||||||
|
['159:9', { bedrock: 'stained_hardened_clay', data: 9, name: 'Cyan Terracotta' }],
|
||||||
|
['159:10', { bedrock: 'stained_hardened_clay', data: 10, name: 'Purple Terracotta' }],
|
||||||
|
['159:11', { bedrock: 'stained_hardened_clay', data: 11, name: 'Blue Terracotta' }],
|
||||||
|
['159:12', { bedrock: 'stained_hardened_clay', data: 12, name: 'Brown Terracotta' }],
|
||||||
|
['159:13', { bedrock: 'stained_hardened_clay', data: 13, name: 'Green Terracotta' }],
|
||||||
|
['159:14', { bedrock: 'stained_hardened_clay', data: 14, name: 'Red Terracotta' }],
|
||||||
|
['159:15', { bedrock: 'stained_hardened_clay', data: 15, name: 'Black Terracotta' }],
|
||||||
|
['172', { bedrock: 'hardened_clay', data: 0, name: 'Terracotta' }],
|
||||||
|
|
||||||
|
// ── Concrete ──
|
||||||
|
['251', { bedrock: 'concrete', data: 0, name: 'White Concrete' }],
|
||||||
|
['251:1', { bedrock: 'concrete', data: 1, name: 'Orange Concrete' }],
|
||||||
|
['251:2', { bedrock: 'concrete', data: 2, name: 'Magenta Concrete' }],
|
||||||
|
['251:3', { bedrock: 'concrete', data: 3, name: 'Light Blue Concrete' }],
|
||||||
|
['251:4', { bedrock: 'concrete', data: 4, name: 'Yellow Concrete' }],
|
||||||
|
['251:5', { bedrock: 'concrete', data: 5, name: 'Lime Concrete' }],
|
||||||
|
['251:6', { bedrock: 'concrete', data: 6, name: 'Pink Concrete' }],
|
||||||
|
['251:7', { bedrock: 'concrete', data: 7, name: 'Gray Concrete' }],
|
||||||
|
['251:8', { bedrock: 'concrete', data: 8, name: 'Light Gray Concrete' }],
|
||||||
|
['251:9', { bedrock: 'concrete', data: 9, name: 'Cyan Concrete' }],
|
||||||
|
['251:10', { bedrock: 'concrete', data: 10, name: 'Purple Concrete' }],
|
||||||
|
['251:11', { bedrock: 'concrete', data: 11, name: 'Blue Concrete' }],
|
||||||
|
['251:12', { bedrock: 'concrete', data: 12, name: 'Brown Concrete' }],
|
||||||
|
['251:13', { bedrock: 'concrete', data: 13, name: 'Green Concrete' }],
|
||||||
|
['251:14', { bedrock: 'concrete', data: 14, name: 'Red Concrete' }],
|
||||||
|
['251:15', { bedrock: 'concrete', data: 15, name: 'Black Concrete' }],
|
||||||
|
|
||||||
|
// ── Concrete Powder ──
|
||||||
|
['252', { bedrock: 'concrete_powder', data: 0, name: 'White Concrete Powder' }],
|
||||||
|
['252:1', { bedrock: 'concrete_powder', data: 1, name: 'Orange Concrete Powder' }],
|
||||||
|
['252:2', { bedrock: 'concrete_powder', data: 2, name: 'Magenta Concrete Powder' }],
|
||||||
|
['252:3', { bedrock: 'concrete_powder', data: 3, name: 'Light Blue Concrete Powder' }],
|
||||||
|
['252:4', { bedrock: 'concrete_powder', data: 4, name: 'Yellow Concrete Powder' }],
|
||||||
|
['252:5', { bedrock: 'concrete_powder', data: 5, name: 'Lime Concrete Powder' }],
|
||||||
|
['252:6', { bedrock: 'concrete_powder', data: 6, name: 'Pink Concrete Powder' }],
|
||||||
|
['252:7', { bedrock: 'concrete_powder', data: 7, name: 'Gray Concrete Powder' }],
|
||||||
|
['252:8', { bedrock: 'concrete_powder', data: 8, name: 'Light Gray Concrete Powder' }],
|
||||||
|
['252:9', { bedrock: 'concrete_powder', data: 9, name: 'Cyan Concrete Powder' }],
|
||||||
|
['252:10', { bedrock: 'concrete_powder', data: 10, name: 'Purple Concrete Powder' }],
|
||||||
|
['252:11', { bedrock: 'concrete_powder', data: 11, name: 'Blue Concrete Powder' }],
|
||||||
|
['252:12', { bedrock: 'concrete_powder', data: 12, name: 'Brown Concrete Powder' }],
|
||||||
|
['252:13', { bedrock: 'concrete_powder', data: 13, name: 'Green Concrete Powder' }],
|
||||||
|
['252:14', { bedrock: 'concrete_powder', data: 14, name: 'Red Concrete Powder' }],
|
||||||
|
['252:15', { bedrock: 'concrete_powder', data: 15, name: 'Black Concrete Powder' }],
|
||||||
|
|
||||||
|
// ── Glazed Terracotta ──
|
||||||
|
['235', { bedrock: 'white_glazed_terracotta', data: 0, name: 'White Glazed Terracotta' }],
|
||||||
|
['236', { bedrock: 'orange_glazed_terracotta', data: 0, name: 'Orange Glazed Terracotta' }],
|
||||||
|
['237', { bedrock: 'magenta_glazed_terracotta', data: 0, name: 'Magenta Glazed Terracotta' }],
|
||||||
|
['238', { bedrock: 'light_blue_glazed_terracotta', data: 0, name: 'Light Blue Glazed Terracotta' }],
|
||||||
|
['239', { bedrock: 'yellow_glazed_terracotta', data: 0, name: 'Yellow Glazed Terracotta' }],
|
||||||
|
['240', { bedrock: 'lime_glazed_terracotta', data: 0, name: 'Lime Glazed Terracotta' }],
|
||||||
|
['241', { bedrock: 'pink_glazed_terracotta', data: 0, name: 'Pink Glazed Terracotta' }],
|
||||||
|
['242', { bedrock: 'gray_glazed_terracotta', data: 0, name: 'Gray Glazed Terracotta' }],
|
||||||
|
['243', { bedrock: 'silver_glazed_terracotta', data: 0, name: 'Light Gray Glazed Terracotta' }],
|
||||||
|
['244', { bedrock: 'cyan_glazed_terracotta', data: 0, name: 'Cyan Glazed Terracotta' }],
|
||||||
|
['245', { bedrock: 'purple_glazed_terracotta', data: 0, name: 'Purple Glazed Terracotta' }],
|
||||||
|
['246', { bedrock: 'blue_glazed_terracotta', data: 0, name: 'Blue Glazed Terracotta' }],
|
||||||
|
['247', { bedrock: 'brown_glazed_terracotta', data: 0, name: 'Brown Glazed Terracotta' }],
|
||||||
|
['248', { bedrock: 'green_glazed_terracotta', data: 0, name: 'Green Glazed Terracotta' }],
|
||||||
|
['249', { bedrock: 'red_glazed_terracotta', data: 0, name: 'Red Glazed Terracotta' }],
|
||||||
|
['250', { bedrock: 'black_glazed_terracotta', data: 0, name: 'Black Glazed Terracotta' }],
|
||||||
|
|
||||||
|
// ── Carpet ──
|
||||||
|
['171', { bedrock: 'carpet', data: 0, name: 'White Carpet' }],
|
||||||
|
['171:1', { bedrock: 'carpet', data: 1, name: 'Orange Carpet' }],
|
||||||
|
['171:2', { bedrock: 'carpet', data: 2, name: 'Magenta Carpet' }],
|
||||||
|
['171:3', { bedrock: 'carpet', data: 3, name: 'Light Blue Carpet' }],
|
||||||
|
['171:4', { bedrock: 'carpet', data: 4, name: 'Yellow Carpet' }],
|
||||||
|
['171:5', { bedrock: 'carpet', data: 5, name: 'Lime Carpet' }],
|
||||||
|
['171:6', { bedrock: 'carpet', data: 6, name: 'Pink Carpet' }],
|
||||||
|
['171:7', { bedrock: 'carpet', data: 7, name: 'Gray Carpet' }],
|
||||||
|
['171:8', { bedrock: 'carpet', data: 8, name: 'Light Gray Carpet' }],
|
||||||
|
['171:9', { bedrock: 'carpet', data: 9, name: 'Cyan Carpet' }],
|
||||||
|
['171:10', { bedrock: 'carpet', data: 10, name: 'Purple Carpet' }],
|
||||||
|
['171:11', { bedrock: 'carpet', data: 11, name: 'Blue Carpet' }],
|
||||||
|
['171:12', { bedrock: 'carpet', data: 12, name: 'Brown Carpet' }],
|
||||||
|
['171:13', { bedrock: 'carpet', data: 13, name: 'Green Carpet' }],
|
||||||
|
['171:14', { bedrock: 'carpet', data: 14, name: 'Red Carpet' }],
|
||||||
|
['171:15', { bedrock: 'carpet', data: 15, name: 'Black Carpet' }],
|
||||||
|
|
||||||
|
// ── Redstone ──
|
||||||
|
['55', { bedrock: 'redstone_wire', data: 0, name: 'Redstone Wire' }],
|
||||||
|
['73', { bedrock: 'redstone_ore', data: 0, name: 'Redstone Ore' }],
|
||||||
|
['76', { bedrock: 'redstone_torch', data: 0, name: 'Redstone Torch' }],
|
||||||
|
['69', { bedrock: 'lever', data: 0, name: 'Lever' }],
|
||||||
|
['70', { bedrock: 'stone_pressure_plate', data: 0, name: 'Stone Pressure Plate' }],
|
||||||
|
['72', { bedrock: 'wooden_pressure_plate', data: 0, name: 'Oak Pressure Plate' }],
|
||||||
|
['77', { bedrock: 'stone_button', data: 0, name: 'Stone Button' }],
|
||||||
|
['143', { bedrock: 'wooden_button', data: 0, name: 'Oak Button' }],
|
||||||
|
['123', { bedrock: 'redstone_lamp', data: 0, name: 'Redstone Lamp' }],
|
||||||
|
['33', { bedrock: 'piston', data: 0, name: 'Piston' }],
|
||||||
|
['29', { bedrock: 'sticky_piston', data: 0, name: 'Sticky Piston' }],
|
||||||
|
['93', { bedrock: 'unpowered_repeater', data: 0, name: 'Repeater' }],
|
||||||
|
['149', { bedrock: 'unpowered_comparator', data: 0, name: 'Comparator' }],
|
||||||
|
['152', { bedrock: 'redstone_block', data: 0, name: 'Block of Redstone' }],
|
||||||
|
['151', { bedrock: 'daylight_detector', data: 0, name: 'Daylight Detector' }],
|
||||||
|
['154', { bedrock: 'hopper', data: 0, name: 'Hopper' }],
|
||||||
|
['158', { bedrock: 'dropper', data: 0, name: 'Dropper' }],
|
||||||
|
['146', { bedrock: 'trapped_chest', data: 0, name: 'Trapped Chest' }],
|
||||||
|
['147', { bedrock: 'light_weighted_pressure_plate', data: 0, name: 'Light Weighted Pressure Plate' }],
|
||||||
|
['148', { bedrock: 'heavy_weighted_pressure_plate', data: 0, name: 'Heavy Weighted Pressure Plate' }],
|
||||||
|
|
||||||
|
// ── Trapdoors ──
|
||||||
|
['96', { bedrock: 'trapdoor', data: 0, name: 'Oak Trapdoor' }],
|
||||||
|
['167', { bedrock: 'iron_trapdoor', data: 0, name: 'Iron Trapdoor' }],
|
||||||
|
|
||||||
|
// ── Emerald ──
|
||||||
|
['129', { bedrock: 'emerald_ore', data: 0, name: 'Emerald Ore' }],
|
||||||
|
['133', { bedrock: 'emerald_block', data: 0, name: 'Block of Emerald' }],
|
||||||
|
|
||||||
|
// ── End Stone ──
|
||||||
|
['121', { bedrock: 'end_stone', data: 0, name: 'End Stone' }],
|
||||||
|
['206', { bedrock: 'end_bricks', data: 0, name: 'End Stone Bricks' }],
|
||||||
|
|
||||||
|
// ── Purpur ──
|
||||||
|
['201', { bedrock: 'purpur_block', data: 0, name: 'Purpur Block' }],
|
||||||
|
['202', { bedrock: 'purpur_pillar', data: 0, name: 'Purpur Pillar' }],
|
||||||
|
['203', { bedrock: 'purpur_stairs', data: 0, name: 'Purpur Stairs' }],
|
||||||
|
|
||||||
|
// ── Prismarine ──
|
||||||
|
['168', { bedrock: 'prismarine', data: 0, name: 'Prismarine' }],
|
||||||
|
['168:1', { bedrock: 'prismarine', data: 1, name: 'Prismarine Bricks' }],
|
||||||
|
['168:2', { bedrock: 'prismarine', data: 2, name: 'Dark Prismarine' }],
|
||||||
|
['169', { bedrock: 'sea_lantern', data: 0, name: 'Sea Lantern' }],
|
||||||
|
|
||||||
|
// ── Hay Bale ──
|
||||||
|
['170', { bedrock: 'hay_block', data: 0, name: 'Hay Bale' }],
|
||||||
|
|
||||||
|
// ── Anvil ──
|
||||||
|
['145', { bedrock: 'anvil', data: 0, name: 'Anvil' }],
|
||||||
|
|
||||||
|
// ── Slime Block ──
|
||||||
|
['165', { bedrock: 'slime', data: 0, name: 'Slime Block' }],
|
||||||
|
|
||||||
|
// ── Coal Block ──
|
||||||
|
['173', { bedrock: 'coal_block', data: 0, name: 'Block of Coal' }],
|
||||||
|
|
||||||
|
// ── Red Sandstone ──
|
||||||
|
['179', { bedrock: 'red_sandstone', data: 0, name: 'Red Sandstone' }],
|
||||||
|
['179:1', { bedrock: 'red_sandstone', data: 1, name: 'Chiseled Red Sandstone' }],
|
||||||
|
['179:2', { bedrock: 'red_sandstone', data: 2, name: 'Smooth Red Sandstone' }],
|
||||||
|
['180', { bedrock: 'red_sandstone_stairs', data: 0, name: 'Red Sandstone Stairs' }],
|
||||||
|
|
||||||
|
// ── Misc utility blocks ──
|
||||||
|
['26', { bedrock: 'bed', data: 0, name: 'Bed' }],
|
||||||
|
['30', { bedrock: 'web', data: 0, name: 'Cobweb' }],
|
||||||
|
['31', { bedrock: 'tallgrass', data: 1, name: 'Grass' }],
|
||||||
|
['31:2', { bedrock: 'tallgrass', data: 2, name: 'Fern' }],
|
||||||
|
['32', { bedrock: 'deadbush', data: 0, name: 'Dead Bush' }],
|
||||||
|
['37', { bedrock: 'yellow_flower', data: 0, name: 'Dandelion' }],
|
||||||
|
['38', { bedrock: 'red_flower', data: 0, name: 'Poppy' }],
|
||||||
|
['38:1', { bedrock: 'red_flower', data: 1, name: 'Blue Orchid' }],
|
||||||
|
['38:2', { bedrock: 'red_flower', data: 2, name: 'Allium' }],
|
||||||
|
['38:3', { bedrock: 'red_flower', data: 3, name: 'Azure Bluet' }],
|
||||||
|
['38:4', { bedrock: 'red_flower', data: 4, name: 'Red Tulip' }],
|
||||||
|
['38:5', { bedrock: 'red_flower', data: 5, name: 'Orange Tulip' }],
|
||||||
|
['38:6', { bedrock: 'red_flower', data: 6, name: 'White Tulip' }],
|
||||||
|
['38:7', { bedrock: 'red_flower', data: 7, name: 'Pink Tulip' }],
|
||||||
|
['38:8', { bedrock: 'red_flower', data: 8, name: 'Oxeye Daisy' }],
|
||||||
|
['39', { bedrock: 'brown_mushroom', data: 0, name: 'Brown Mushroom' }],
|
||||||
|
['40', { bedrock: 'red_mushroom', data: 0, name: 'Red Mushroom' }],
|
||||||
|
['83', { bedrock: 'reeds', data: 0, name: 'Sugar Cane' }],
|
||||||
|
['100', { bedrock: 'red_mushroom_block', data: 0, name: 'Red Mushroom Block' }],
|
||||||
|
['99', { bedrock: 'brown_mushroom_block', data: 0, name: 'Brown Mushroom Block' }],
|
||||||
|
['104', { bedrock: 'pumpkin_stem', data: 0, name: 'Pumpkin Stem' }],
|
||||||
|
['106', { bedrock: 'vine', data: 0, name: 'Vines' }],
|
||||||
|
['110', { bedrock: 'mycelium', data: 0, name: 'Mycelium' }],
|
||||||
|
['111', { bedrock: 'waterlily', data: 0, name: 'Lily Pad' }],
|
||||||
|
['115', { bedrock: 'nether_wart', data: 0, name: 'Nether Wart' }],
|
||||||
|
['116', { bedrock: 'enchanting_table', data: 0, name: 'Enchanting Table' }],
|
||||||
|
['117', { bedrock: 'brewing_stand', data: 0, name: 'Brewing Stand' }],
|
||||||
|
['118', { bedrock: 'cauldron', data: 0, name: 'Cauldron' }],
|
||||||
|
['120', { bedrock: 'end_portal_frame', data: 0, name: 'End Portal Frame' }],
|
||||||
|
['122', { bedrock: 'dragon_egg', data: 0, name: 'Dragon Egg' }],
|
||||||
|
['130', { bedrock: 'ender_chest', data: 0, name: 'Ender Chest' }],
|
||||||
|
['138', { bedrock: 'beacon', data: 0, name: 'Beacon' }],
|
||||||
|
['166', { bedrock: 'barrier', data: 0, name: 'Barrier' }],
|
||||||
|
['175', { bedrock: 'double_plant', data: 0, name: 'Sunflower' }],
|
||||||
|
['175:1', { bedrock: 'double_plant', data: 1, name: 'Lilac' }],
|
||||||
|
['175:2', { bedrock: 'double_plant', data: 2, name: 'Double Tallgrass' }],
|
||||||
|
['175:3', { bedrock: 'double_plant', data: 3, name: 'Large Fern' }],
|
||||||
|
['175:4', { bedrock: 'double_plant', data: 4, name: 'Rose Bush' }],
|
||||||
|
['175:5', { bedrock: 'double_plant', data: 5, name: 'Peony' }],
|
||||||
|
['198', { bedrock: 'end_rod', data: 0, name: 'End Rod' }],
|
||||||
|
['199', { bedrock: 'chorus_plant', data: 0, name: 'Chorus Plant' }],
|
||||||
|
['200', { bedrock: 'chorus_flower', data: 0, name: 'Chorus Flower' }],
|
||||||
|
['207', { bedrock: 'beetroot', data: 0, name: 'Beetroot' }],
|
||||||
|
['208', { bedrock: 'grass_path', data: 0, name: 'Grass Path' }],
|
||||||
|
['209', { bedrock: 'end_gateway', data: 0, name: 'End Gateway' }],
|
||||||
|
['213', { bedrock: 'magma', data: 0, name: 'Magma Block' }],
|
||||||
|
['214', { bedrock: 'nether_wart_block', data: 0, name: 'Nether Wart Block' }],
|
||||||
|
['215', { bedrock: 'red_nether_brick', data: 0, name: 'Red Nether Bricks' }],
|
||||||
|
['216', { bedrock: 'bone_block', data: 0, name: 'Bone Block' }],
|
||||||
|
['218', { bedrock: 'observer', data: 0, name: 'Observer' }],
|
||||||
|
['219', { bedrock: 'shulker_box', data: 0, name: 'White Shulker Box' }],
|
||||||
|
|
||||||
|
// ── Walls ──
|
||||||
|
['139', { bedrock: 'cobblestone_wall', data: 0, name: 'Cobblestone Wall' }],
|
||||||
|
['139:1', { bedrock: 'cobblestone_wall', data: 1, name: 'Mossy Cobblestone Wall' }],
|
||||||
|
|
||||||
|
// ── Banners ──
|
||||||
|
['176', { bedrock: 'standing_banner', data: 0, name: 'Banner' }],
|
||||||
|
|
||||||
|
// ── Signs ──
|
||||||
|
['63', { bedrock: 'standing_sign', data: 0, name: 'Sign' }],
|
||||||
|
['68', { bedrock: 'wall_sign', data: 0, name: 'Wall Sign' }],
|
||||||
|
|
||||||
|
// ── Flower Pot ──
|
||||||
|
['140', { bedrock: 'flower_pot', data: 0, name: 'Flower Pot' }],
|
||||||
|
|
||||||
|
// ── Skull / Head ──
|
||||||
|
['144', { bedrock: 'skull', data: 0, name: 'Mob Head' }],
|
||||||
|
|
||||||
|
// ── Armor Stand (entity, but GrabCraft uses it) ──
|
||||||
|
['416', { bedrock: 'air', data: 0, name: 'Armor Stand (entity)' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name-based fuzzy lookup table (lowercase name -> bedrock ID).
|
||||||
|
* Built from BLOCK_MAP for fallback matching when numeric ID fails.
|
||||||
|
*/
|
||||||
|
const NAME_MAP = new Map();
|
||||||
|
for (const [, entry] of BLOCK_MAP) {
|
||||||
|
NAME_MAP.set(entry.name.toLowerCase(), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional common name aliases
|
||||||
|
const ALIASES = new Map([
|
||||||
|
['dark oak wood plank', BLOCK_MAP.get('5:5')],
|
||||||
|
['oak wood plank', BLOCK_MAP.get('5')],
|
||||||
|
['spruce wood plank', BLOCK_MAP.get('5:1')],
|
||||||
|
['birch wood plank', BLOCK_MAP.get('5:2')],
|
||||||
|
['jungle wood plank', BLOCK_MAP.get('5:3')],
|
||||||
|
['acacia wood plank', BLOCK_MAP.get('5:4')],
|
||||||
|
['dark oak wood', BLOCK_MAP.get('162:1')],
|
||||||
|
['oak wood', BLOCK_MAP.get('17')],
|
||||||
|
['spruce wood', BLOCK_MAP.get('17:1')],
|
||||||
|
['birch wood', BLOCK_MAP.get('17:2')],
|
||||||
|
['jungle wood', BLOCK_MAP.get('17:3')],
|
||||||
|
['acacia wood', BLOCK_MAP.get('162')],
|
||||||
|
['stone brick', BLOCK_MAP.get('98')],
|
||||||
|
['mossy stone brick', BLOCK_MAP.get('98:1')],
|
||||||
|
['cracked stone brick', BLOCK_MAP.get('98:2')],
|
||||||
|
['chiseled stone brick', BLOCK_MAP.get('98:3')],
|
||||||
|
['brick', BLOCK_MAP.get('45')],
|
||||||
|
['nether brick', BLOCK_MAP.get('112')],
|
||||||
|
['glass pane', BLOCK_MAP.get('102')],
|
||||||
|
['cobble', BLOCK_MAP.get('4')],
|
||||||
|
['plank', BLOCK_MAP.get('5')],
|
||||||
|
['planks', BLOCK_MAP.get('5')],
|
||||||
|
['wooden plank', BLOCK_MAP.get('5')],
|
||||||
|
['wooden planks', BLOCK_MAP.get('5')],
|
||||||
|
['log', BLOCK_MAP.get('17')],
|
||||||
|
['wood', BLOCK_MAP.get('17')],
|
||||||
|
['leaves', BLOCK_MAP.get('18')],
|
||||||
|
['torch', BLOCK_MAP.get('50')],
|
||||||
|
['crafting table', BLOCK_MAP.get('58')],
|
||||||
|
['workbench', BLOCK_MAP.get('58')],
|
||||||
|
['furnace', BLOCK_MAP.get('61')],
|
||||||
|
['chest', BLOCK_MAP.get('54')],
|
||||||
|
['door', BLOCK_MAP.get('64')],
|
||||||
|
['fence', BLOCK_MAP.get('85')],
|
||||||
|
['wool', BLOCK_MAP.get('35')],
|
||||||
|
['carpet', BLOCK_MAP.get('171')],
|
||||||
|
['glass', BLOCK_MAP.get('20')],
|
||||||
|
['sand', BLOCK_MAP.get('12')],
|
||||||
|
['gravel', BLOCK_MAP.get('13')],
|
||||||
|
['dirt', BLOCK_MAP.get('3')],
|
||||||
|
['grass', BLOCK_MAP.get('2')],
|
||||||
|
['grass block', BLOCK_MAP.get('2')],
|
||||||
|
['water', BLOCK_MAP.get('8')],
|
||||||
|
['lava', BLOCK_MAP.get('10')],
|
||||||
|
['cobblestone wall', BLOCK_MAP.get('139')],
|
||||||
|
['mossy cobblestone wall', BLOCK_MAP.get('139:1')],
|
||||||
|
['redstone', BLOCK_MAP.get('55')],
|
||||||
|
['redstone lamp', BLOCK_MAP.get('123')],
|
||||||
|
['glowstone', BLOCK_MAP.get('89')],
|
||||||
|
['sea lantern', BLOCK_MAP.get('169')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [name, entry] of ALIASES) {
|
||||||
|
if (entry) NAME_MAP.set(name, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track unknown blocks for reporting */
|
||||||
|
const unknownBlocks = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a GrabCraft block ID and/or name to a Bedrock setblock string.
|
||||||
|
* @param {string} gcId - GrabCraft numeric ID like "5:5" or "5"
|
||||||
|
* @param {string} [gcName] - English name from GrabCraft for fuzzy matching
|
||||||
|
* @returns {{ block: string, data: number, matched: boolean, name: string }}
|
||||||
|
*/
|
||||||
|
export function resolveBlock(gcId, gcName) {
|
||||||
|
// Try exact numeric ID first
|
||||||
|
if (gcId && BLOCK_MAP.has(gcId)) {
|
||||||
|
const entry = BLOCK_MAP.get(gcId);
|
||||||
|
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try base ID without data value
|
||||||
|
if (gcId && gcId.includes(':')) {
|
||||||
|
const baseId = gcId.split(':')[0];
|
||||||
|
if (BLOCK_MAP.has(baseId)) {
|
||||||
|
const entry = BLOCK_MAP.get(baseId);
|
||||||
|
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try name-based lookup
|
||||||
|
if (gcName) {
|
||||||
|
const lower = gcName.toLowerCase().trim();
|
||||||
|
if (NAME_MAP.has(lower)) {
|
||||||
|
const entry = NAME_MAP.get(lower);
|
||||||
|
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy: try removing common suffixes/prefixes
|
||||||
|
const simplified = lower
|
||||||
|
.replace(/\b(block of|block)\b/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (NAME_MAP.has(simplified)) {
|
||||||
|
const entry = NAME_MAP.get(simplified);
|
||||||
|
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial match: check if any name contains the search term
|
||||||
|
for (const [name, entry] of NAME_MAP) {
|
||||||
|
if (name.includes(lower) || lower.includes(name)) {
|
||||||
|
return { block: entry.bedrock, data: entry.data, matched: true, name: entry.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track unknown block
|
||||||
|
const key = `${gcId || 'unknown'}:${gcName || 'unnamed'}`;
|
||||||
|
unknownBlocks.set(key, (unknownBlocks.get(key) || 0) + 1);
|
||||||
|
|
||||||
|
// Fallback to stone
|
||||||
|
log(TAG, `Unknown block: id=${gcId}, name=${gcName} — using stone fallback`);
|
||||||
|
return { block: 'stone', data: 0, matched: false, name: gcName || `Unknown(${gcId})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a resolved block for a setblock command.
|
||||||
|
* @param {{ block: string, data: number }} resolved
|
||||||
|
* @returns {string} e.g. "planks 5" or "stone 0"
|
||||||
|
*/
|
||||||
|
export function formatBlock(resolved) {
|
||||||
|
return resolved.data > 0 ? `${resolved.block} ${resolved.data}` : resolved.block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unknown blocks encountered so far (for reporting).
|
||||||
|
* @returns {Map<string, number>}
|
||||||
|
*/
|
||||||
|
export function getUnknownBlocks() {
|
||||||
|
return new Map(unknownBlocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the unknown blocks tracker.
|
||||||
|
*/
|
||||||
|
export function clearUnknownBlocks() {
|
||||||
|
unknownBlocks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known block mappings (for MCP resource).
|
||||||
|
* @returns {Array<{ javaId: string, bedrockId: string, bedrockData: number, name: string }>}
|
||||||
|
*/
|
||||||
|
export function getAllBlocks() {
|
||||||
|
const blocks = [];
|
||||||
|
for (const [javaId, entry] of BLOCK_MAP) {
|
||||||
|
blocks.push({
|
||||||
|
javaId,
|
||||||
|
bedrockId: entry.bedrock,
|
||||||
|
bedrockData: entry.data,
|
||||||
|
name: entry.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
272
src/building-helpers.js
Normal file
272
src/building-helpers.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Higher-level geometric primitives for Minecraft building.
|
||||||
|
* Generates arrays of setblock/fill commands.
|
||||||
|
* Uses `fill` for rectangular regions where possible for efficiency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sphere of blocks.
|
||||||
|
* @param {{ x: number, y: number, z: number }} center
|
||||||
|
* @param {number} radius
|
||||||
|
* @param {string} block - Bedrock block ID (e.g. "stone", "glass")
|
||||||
|
* @param {boolean} [hollow=false] - If true, only the shell
|
||||||
|
* @returns {string[]} Array of setblock commands
|
||||||
|
*/
|
||||||
|
export function generateSphere(center, radius, block, hollow = false) {
|
||||||
|
const commands = [];
|
||||||
|
const r2 = radius * radius;
|
||||||
|
const inner2 = hollow ? (radius - 1) * (radius - 1) : -1;
|
||||||
|
|
||||||
|
for (let y = -radius; y <= radius; y++) {
|
||||||
|
for (let x = -radius; x <= radius; x++) {
|
||||||
|
for (let z = -radius; z <= radius; z++) {
|
||||||
|
const dist2 = x * x + y * y + z * z;
|
||||||
|
if (dist2 <= r2) {
|
||||||
|
if (!hollow || dist2 > inner2) {
|
||||||
|
commands.push(`setblock ${center.x + x} ${center.y + y} ${center.z + z} ${block}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cylinder of blocks.
|
||||||
|
* @param {{ x: number, y: number, z: number }} base - Bottom center
|
||||||
|
* @param {number} radius
|
||||||
|
* @param {number} height
|
||||||
|
* @param {string} block
|
||||||
|
* @param {boolean} [hollow=false]
|
||||||
|
* @returns {string[]} Array of commands (uses fill for full layers)
|
||||||
|
*/
|
||||||
|
export function generateCylinder(base, radius, height, block, hollow = false) {
|
||||||
|
const commands = [];
|
||||||
|
const r2 = radius * radius;
|
||||||
|
const inner2 = hollow ? (radius - 1) * (radius - 1) : -1;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
if (!hollow) {
|
||||||
|
// For solid cylinders, collect rows and use fill where possible
|
||||||
|
const rows = collectCircleRows(base.x, base.z, radius);
|
||||||
|
for (const row of rows) {
|
||||||
|
commands.push(`fill ${row.x1} ${base.y + y} ${row.z} ${row.x2} ${base.y + y} ${row.z} ${block}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let x = -radius; x <= radius; x++) {
|
||||||
|
for (let z = -radius; z <= radius; z++) {
|
||||||
|
const dist2 = x * x + z * z;
|
||||||
|
if (dist2 <= r2 && dist2 > inner2) {
|
||||||
|
commands.push(`setblock ${base.x + x} ${base.y + y} ${base.z + z} ${block}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a dome (half-sphere on top of a base point).
|
||||||
|
* @param {{ x: number, y: number, z: number }} base - Center of the dome base
|
||||||
|
* @param {number} radius
|
||||||
|
* @param {string} block
|
||||||
|
* @returns {string[]} Array of setblock commands
|
||||||
|
*/
|
||||||
|
export function generateDome(base, radius, block) {
|
||||||
|
const commands = [];
|
||||||
|
const r2 = radius * radius;
|
||||||
|
const inner2 = (radius - 1) * (radius - 1);
|
||||||
|
|
||||||
|
for (let y = 0; y <= radius; y++) {
|
||||||
|
for (let x = -radius; x <= radius; x++) {
|
||||||
|
for (let z = -radius; z <= radius; z++) {
|
||||||
|
const dist2 = x * x + y * y + z * z;
|
||||||
|
if (dist2 <= r2 && dist2 > inner2) {
|
||||||
|
commands.push(`setblock ${base.x + x} ${base.y + y} ${base.z + z} ${block}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a pyramid.
|
||||||
|
* @param {{ x: number, y: number, z: number }} base - Center of the pyramid base
|
||||||
|
* @param {number} size - Base half-width
|
||||||
|
* @param {string} block
|
||||||
|
* @param {boolean} [hollow=false]
|
||||||
|
* @returns {string[]} Array of commands (uses fill for layers)
|
||||||
|
*/
|
||||||
|
export function generatePyramid(base, size, block, hollow = false) {
|
||||||
|
const commands = [];
|
||||||
|
|
||||||
|
for (let layer = 0; layer <= size; layer++) {
|
||||||
|
const halfWidth = size - layer;
|
||||||
|
const y = base.y + layer;
|
||||||
|
|
||||||
|
if (halfWidth === 0) {
|
||||||
|
// Peak - single block
|
||||||
|
commands.push(`setblock ${base.x} ${y} ${base.z} ${block}`);
|
||||||
|
} else if (!hollow) {
|
||||||
|
// Solid layer - single fill command
|
||||||
|
commands.push(
|
||||||
|
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth} ${base.x + halfWidth} ${y} ${base.z + halfWidth} ${block}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Hollow - only the perimeter of each layer
|
||||||
|
// Four edges using fill
|
||||||
|
commands.push(
|
||||||
|
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth} ${base.x + halfWidth} ${y} ${base.z - halfWidth} ${block}`
|
||||||
|
);
|
||||||
|
commands.push(
|
||||||
|
`fill ${base.x - halfWidth} ${y} ${base.z + halfWidth} ${base.x + halfWidth} ${y} ${base.z + halfWidth} ${block}`
|
||||||
|
);
|
||||||
|
if (halfWidth > 1) {
|
||||||
|
commands.push(
|
||||||
|
`fill ${base.x - halfWidth} ${y} ${base.z - halfWidth + 1} ${base.x - halfWidth} ${y} ${base.z + halfWidth - 1} ${block}`
|
||||||
|
);
|
||||||
|
commands.push(
|
||||||
|
`fill ${base.x + halfWidth} ${y} ${base.z - halfWidth + 1} ${base.x + halfWidth} ${y} ${base.z + halfWidth - 1} ${block}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a wall between two points.
|
||||||
|
* @param {{ x: number, y: number, z: number }} start
|
||||||
|
* @param {{ x: number, y: number, z: number }} end
|
||||||
|
* @param {number} height
|
||||||
|
* @param {string} block
|
||||||
|
* @returns {string[]} Array of fill commands
|
||||||
|
*/
|
||||||
|
export function generateWall(start, end, height, block) {
|
||||||
|
const commands = [];
|
||||||
|
|
||||||
|
// Use fill for the entire wall (works for axis-aligned and diagonal)
|
||||||
|
const x1 = Math.min(start.x, end.x);
|
||||||
|
const x2 = Math.max(start.x, end.x);
|
||||||
|
const z1 = Math.min(start.z, end.z);
|
||||||
|
const z2 = Math.max(start.z, end.z);
|
||||||
|
const y1 = Math.min(start.y, end.y);
|
||||||
|
const y2 = y1 + height - 1;
|
||||||
|
|
||||||
|
// If axis-aligned, single fill command
|
||||||
|
if (x1 === x2 || z1 === z2) {
|
||||||
|
commands.push(`fill ${x1} ${y1} ${z1} ${x2} ${y2} ${z2} ${block}`);
|
||||||
|
} else {
|
||||||
|
// Diagonal wall: use Bresenham-style line of fill columns
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dz = end.z - start.z;
|
||||||
|
const steps = Math.max(Math.abs(dx), Math.abs(dz));
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = steps === 0 ? 0 : i / steps;
|
||||||
|
const wx = Math.round(start.x + dx * t);
|
||||||
|
const wz = Math.round(start.z + dz * t);
|
||||||
|
commands.push(`fill ${wx} ${y1} ${wz} ${wx} ${y2} ${wz} ${block}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a box (rectangular prism).
|
||||||
|
* @param {{ x: number, y: number, z: number }} corner1
|
||||||
|
* @param {{ x: number, y: number, z: number }} corner2
|
||||||
|
* @param {string} block
|
||||||
|
* @param {boolean} [hollow=false]
|
||||||
|
* @returns {string[]} Array of fill commands
|
||||||
|
*/
|
||||||
|
export function generateBox(corner1, corner2, block, hollow = false) {
|
||||||
|
if (!hollow) {
|
||||||
|
return [
|
||||||
|
`fill ${corner1.x} ${corner1.y} ${corner1.z} ${corner2.x} ${corner2.y} ${corner2.z} ${block}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hollow box: 6 faces
|
||||||
|
const x1 = Math.min(corner1.x, corner2.x);
|
||||||
|
const x2 = Math.max(corner1.x, corner2.x);
|
||||||
|
const y1 = Math.min(corner1.y, corner2.y);
|
||||||
|
const y2 = Math.max(corner1.y, corner2.y);
|
||||||
|
const z1 = Math.min(corner1.z, corner2.z);
|
||||||
|
const z2 = Math.max(corner1.z, corner2.z);
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Bottom and top faces
|
||||||
|
`fill ${x1} ${y1} ${z1} ${x2} ${y1} ${z2} ${block}`,
|
||||||
|
`fill ${x1} ${y2} ${z1} ${x2} ${y2} ${z2} ${block}`,
|
||||||
|
// Front and back walls
|
||||||
|
`fill ${x1} ${y1 + 1} ${z1} ${x2} ${y2 - 1} ${z1} ${block}`,
|
||||||
|
`fill ${x1} ${y1 + 1} ${z2} ${x2} ${y2 - 1} ${z2} ${block}`,
|
||||||
|
// Left and right walls
|
||||||
|
`fill ${x1} ${y1 + 1} ${z1 + 1} ${x1} ${y2 - 1} ${z2 - 1} ${block}`,
|
||||||
|
`fill ${x2} ${y1 + 1} ${z1 + 1} ${x2} ${y2 - 1} ${z2 - 1} ${block}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect circle rows for efficient fill commands.
|
||||||
|
* Returns horizontal line segments that fill a circular cross-section.
|
||||||
|
*/
|
||||||
|
function collectCircleRows(cx, cz, radius) {
|
||||||
|
const rows = [];
|
||||||
|
const r2 = radius * radius;
|
||||||
|
|
||||||
|
for (let z = -radius; z <= radius; z++) {
|
||||||
|
// Find the x extent at this z
|
||||||
|
const maxX = Math.floor(Math.sqrt(r2 - z * z));
|
||||||
|
if (maxX >= 0) {
|
||||||
|
rows.push({ x1: cx - maxX, x2: cx + maxX, z: cz + z });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of shape names to generator functions and their parameters.
|
||||||
|
*/
|
||||||
|
export const SHAPES = {
|
||||||
|
sphere: {
|
||||||
|
generate: generateSphere,
|
||||||
|
params: ['center', 'radius', 'block', 'hollow'],
|
||||||
|
description: 'A sphere centered at a point',
|
||||||
|
},
|
||||||
|
cylinder: {
|
||||||
|
generate: generateCylinder,
|
||||||
|
params: ['base', 'radius', 'height', 'block', 'hollow'],
|
||||||
|
description: 'A cylinder from a base point upward',
|
||||||
|
},
|
||||||
|
dome: {
|
||||||
|
generate: generateDome,
|
||||||
|
params: ['base', 'radius', 'block'],
|
||||||
|
description: 'A half-sphere dome from a base point',
|
||||||
|
},
|
||||||
|
pyramid: {
|
||||||
|
generate: generatePyramid,
|
||||||
|
params: ['base', 'size', 'block', 'hollow'],
|
||||||
|
description: 'A pyramid from a base center point',
|
||||||
|
},
|
||||||
|
wall: {
|
||||||
|
generate: generateWall,
|
||||||
|
params: ['start', 'end', 'height', 'block'],
|
||||||
|
description: 'A wall between two points',
|
||||||
|
},
|
||||||
|
box: {
|
||||||
|
generate: generateBox,
|
||||||
|
params: ['corner1', 'corner2', 'block', 'hollow'],
|
||||||
|
description: 'A rectangular box between two corners',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,181 +1,241 @@
|
|||||||
import { log, logError } from './utils.js';
|
import { log, logError } from './utils.js';
|
||||||
|
|
||||||
const TAG = 'CommandQueue';
|
const TAG = 'CommandQueue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate-limited command dispatcher for Minecraft Bedrock.
|
* Rate-limited command dispatcher for Minecraft Bedrock.
|
||||||
* Bedrock has a hard limit of ~100 in-flight commands.
|
* Bedrock has a hard limit of ~100 in-flight commands.
|
||||||
* We cap at 80 and throttle at 50ms between commands.
|
* We cap at 80 and throttle at 50ms between commands.
|
||||||
*/
|
*/
|
||||||
export class CommandQueue {
|
export class CommandQueue {
|
||||||
/**
|
/**
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
|
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
|
||||||
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
||||||
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
|
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
|
||||||
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
||||||
*/
|
* @param {number} opts.maxBuildCommands - Max commands per build (default from env or 5000)
|
||||||
constructor(opts = {}) {
|
*/
|
||||||
this.maxInFlight = opts.maxInFlight ?? 80;
|
constructor(opts = {}) {
|
||||||
this.throttleMs = opts.throttleMs ?? 50;
|
this.maxInFlight = opts.maxInFlight ?? 80;
|
||||||
this.batchSize = opts.batchSize ?? 20;
|
this.throttleMs = opts.throttleMs ?? 50;
|
||||||
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
this.batchSize = opts.batchSize ?? 20;
|
||||||
|
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
||||||
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
this.maxBuildCommands = opts.maxBuildCommands ?? parseInt(process.env.MAX_BUILD_COMMANDS || '5000', 10);
|
||||||
this._pending = new Map();
|
|
||||||
this._queue = [];
|
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
||||||
this._processing = false;
|
this._pending = new Map();
|
||||||
this._sendFn = null;
|
this._queue = [];
|
||||||
this._totalSent = 0;
|
this._processing = false;
|
||||||
this._totalCompleted = 0;
|
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) {
|
* Set the function used to actually send a command over WebSocket.
|
||||||
this._sendFn = fn;
|
* @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
|
/**
|
||||||
*/
|
* Called when a command response comes back from Bedrock.
|
||||||
handleResponse(requestId, response) {
|
* @param {string} requestId
|
||||||
const entry = this._pending.get(requestId);
|
* @param {object} response
|
||||||
if (entry) {
|
*/
|
||||||
clearTimeout(entry.timer);
|
handleResponse(requestId, response) {
|
||||||
this._pending.delete(requestId);
|
const entry = this._pending.get(requestId);
|
||||||
this._totalCompleted++;
|
if (entry) {
|
||||||
entry.resolve(response);
|
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)
|
* Enqueue a single command for dispatch.
|
||||||
* @returns {Promise<object>} Resolves with Bedrock response
|
* @param {string} id - Request UUID
|
||||||
*/
|
* @param {string} message - Serialized WS message
|
||||||
enqueue(id, message, timeoutMs = 10000) {
|
* @param {number} timeoutMs - Per-command timeout (default 10s)
|
||||||
return new Promise((resolve, reject) => {
|
* @returns {Promise<object>} Resolves with Bedrock response
|
||||||
this._queue.push({ id, message, resolve, reject, timeoutMs });
|
*/
|
||||||
this._processQueue();
|
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}>}
|
* Enqueue a batch of commands (for building).
|
||||||
*/
|
* Sends in groups of batchSize with batchDelayMs between groups.
|
||||||
async enqueueBatch(commands) {
|
* @param {Array<{id: string, message: string}>} commands
|
||||||
const results = [];
|
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
|
||||||
let succeeded = 0;
|
*/
|
||||||
let failed = 0;
|
async enqueueBatch(commands) {
|
||||||
|
const results = [];
|
||||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
let succeeded = 0;
|
||||||
const batch = commands.slice(i, i + this.batchSize);
|
let failed = 0;
|
||||||
|
|
||||||
const batchResults = await Promise.allSettled(
|
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||||
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
const batch = commands.slice(i, i + this.batchSize);
|
||||||
);
|
|
||||||
|
const batchResults = await Promise.allSettled(
|
||||||
for (const result of batchResults) {
|
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||||
if (result.status === 'fulfilled') {
|
);
|
||||||
succeeded++;
|
|
||||||
results.push(result.value);
|
for (const result of batchResults) {
|
||||||
} else {
|
if (result.status === 'fulfilled') {
|
||||||
failed++;
|
succeeded++;
|
||||||
results.push({ error: result.reason?.message || 'unknown error' });
|
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);
|
|
||||||
}
|
// 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 */
|
return { total: commands.length, succeeded, failed, results };
|
||||||
async _processQueue() {
|
}
|
||||||
if (this._processing) return;
|
|
||||||
this._processing = true;
|
/**
|
||||||
|
* Enqueue a batch with progress reporting.
|
||||||
while (this._queue.length > 0) {
|
* Calls progressFn with status updates between layer batches.
|
||||||
// Wait if at capacity
|
* @param {Array<{id: string, message: string}>} commands
|
||||||
if (this._pending.size >= this.maxInFlight) {
|
* @param {(progress: { completed: number, total: number, percent: number }) => void} [progressFn]
|
||||||
await this._delay(this.throttleMs);
|
* @returns {Promise<{total: number, succeeded: number, failed: number, cancelled: boolean, results: Array}>}
|
||||||
continue;
|
*/
|
||||||
}
|
async enqueueBatchWithProgress(commands, progressFn) {
|
||||||
|
this._cancelBuild = false;
|
||||||
const item = this._queue.shift();
|
const results = [];
|
||||||
if (!item) break;
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
if (!this._sendFn) {
|
|
||||||
item.reject(new Error('No WebSocket connection'));
|
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||||
continue;
|
// Check cancellation
|
||||||
}
|
if (this._cancelBuild) {
|
||||||
|
log(TAG, `Build cancelled at ${i}/${commands.length} commands`);
|
||||||
// Set up timeout
|
return { total: commands.length, succeeded, failed, cancelled: true, results };
|
||||||
const timer = setTimeout(() => {
|
}
|
||||||
const entry = this._pending.get(item.id);
|
|
||||||
if (entry) {
|
const batch = commands.slice(i, i + this.batchSize);
|
||||||
this._pending.delete(item.id);
|
|
||||||
entry.reject(new Error('Command timed out'));
|
const batchResults = await Promise.allSettled(
|
||||||
}
|
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||||
}, item.timeoutMs);
|
);
|
||||||
|
|
||||||
this._pending.set(item.id, {
|
for (const result of batchResults) {
|
||||||
resolve: item.resolve,
|
if (result.status === 'fulfilled') {
|
||||||
reject: item.reject,
|
succeeded++;
|
||||||
timer,
|
results.push(result.value);
|
||||||
});
|
} else {
|
||||||
|
failed++;
|
||||||
try {
|
results.push({ error: result.reason?.message || 'unknown error' });
|
||||||
this._sendFn(item.id, item.message);
|
}
|
||||||
this._totalSent++;
|
}
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timer);
|
// Report progress
|
||||||
this._pending.delete(item.id);
|
const completed = i + batch.length;
|
||||||
item.reject(err);
|
const percent = Math.round((completed / commands.length) * 100);
|
||||||
}
|
if (progressFn) {
|
||||||
|
progressFn({ completed, total: commands.length, percent });
|
||||||
// Throttle between sends
|
}
|
||||||
await this._delay(this.throttleMs);
|
|
||||||
}
|
// Delay between batches (except after the last one)
|
||||||
|
if (i + this.batchSize < commands.length) {
|
||||||
this._processing = false;
|
await this._delay(this.batchDelayMs);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
|
|
||||||
getStatus() {
|
return { total: commands.length, succeeded, failed, cancelled: false, results };
|
||||||
return {
|
}
|
||||||
queueSize: this._queue.length,
|
|
||||||
inFlight: this._pending.size,
|
/** Cancel an in-progress build */
|
||||||
totalSent: this._totalSent,
|
cancelBuild() {
|
||||||
totalCompleted: this._totalCompleted,
|
this._cancelBuild = true;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
/** Process queued commands respecting rate limits */
|
||||||
_delay(ms) {
|
async _processQueue() {
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
if (this._processing) return;
|
||||||
}
|
this._processing = true;
|
||||||
|
|
||||||
/** Clean up all pending timeouts */
|
while (this._queue.length > 0) {
|
||||||
destroy() {
|
// Wait if at capacity
|
||||||
for (const entry of this._pending.values()) {
|
if (this._pending.size >= this.maxInFlight) {
|
||||||
clearTimeout(entry.timer);
|
await this._delay(this.throttleMs);
|
||||||
entry.reject(new Error('Queue destroyed'));
|
continue;
|
||||||
}
|
}
|
||||||
this._pending.clear();
|
|
||||||
this._queue = [];
|
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
107
src/encryption.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createECDH, createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
import { log, logError } from './utils.js';
|
||||||
|
|
||||||
|
const TAG = 'Encryption';
|
||||||
|
|
||||||
|
// ASN.1 DER header for a P-384 (secp384r1) uncompressed public key
|
||||||
|
const ASN1_HEADER = Buffer.from(
|
||||||
|
'3076301006072a8648ce3d020106052b81040022036200',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles Bedrock's application-level encryption handshake.
|
||||||
|
*
|
||||||
|
* Protocol:
|
||||||
|
* 1. Server generates ECDH keypair on secp384r1
|
||||||
|
* 2. Server sends `enableencryption` with its public key + random salt
|
||||||
|
* 3. Client responds with its public key
|
||||||
|
* 4. Both derive: key = SHA-256(salt + ECDH_shared_secret)
|
||||||
|
* 5. IV = key[0..16], cipher = AES-256-CFB8 (streaming, stateful)
|
||||||
|
*/
|
||||||
|
export class ServerEncryption {
|
||||||
|
constructor() {
|
||||||
|
this._ecdh = createECDH('secp384r1');
|
||||||
|
this._ecdh.generateKeys();
|
||||||
|
this._salt = randomBytes(16);
|
||||||
|
|
||||||
|
/** @type {import('node:crypto').Cipher | null} */
|
||||||
|
this._cipher = null;
|
||||||
|
/** @type {import('node:crypto').Decipher | null} */
|
||||||
|
this._decipher = null;
|
||||||
|
this._enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parameters needed for the enableencryption command.
|
||||||
|
* @returns {{ publicKey: string, salt: string }} base64-encoded values
|
||||||
|
*/
|
||||||
|
getKeyExchangeParams() {
|
||||||
|
// Bedrock expects the public key wrapped in ASN.1 DER format
|
||||||
|
const rawPub = this._ecdh.getPublicKey();
|
||||||
|
const derPub = Buffer.concat([ASN1_HEADER, rawPub]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: derPub.toString('base64'),
|
||||||
|
salt: this._salt.toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the key exchange using the client's public key.
|
||||||
|
* After this call, encrypt() and decrypt() are operational.
|
||||||
|
* @param {string} clientPubKeyBase64 - Client's base64-encoded public key (may have ASN.1 header)
|
||||||
|
*/
|
||||||
|
completeKeyExchange(clientPubKeyBase64) {
|
||||||
|
let clientPubRaw = Buffer.from(clientPubKeyBase64, 'base64');
|
||||||
|
|
||||||
|
// Strip ASN.1 header if present (Bedrock sends the raw key wrapped in DER)
|
||||||
|
if (clientPubRaw.length > 97) {
|
||||||
|
// P-384 uncompressed point is 97 bytes (0x04 + 48 + 48)
|
||||||
|
clientPubRaw = clientPubRaw.slice(clientPubRaw.length - 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedSecret = this._ecdh.computeSecret(clientPubRaw);
|
||||||
|
|
||||||
|
// key = SHA-256(salt + shared_secret)
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(this._salt);
|
||||||
|
hash.update(sharedSecret);
|
||||||
|
const key = hash.digest();
|
||||||
|
|
||||||
|
// IV = first 16 bytes of key
|
||||||
|
const iv = key.slice(0, 16);
|
||||||
|
|
||||||
|
this._cipher = createCipheriv('aes-256-cfb8', key, iv);
|
||||||
|
this._decipher = createDecipheriv('aes-256-cfb8', key, iv);
|
||||||
|
this._enabled = true;
|
||||||
|
|
||||||
|
log(TAG, 'Encryption handshake complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext message.
|
||||||
|
* @param {string|Buffer} plaintext
|
||||||
|
* @returns {Buffer} ciphertext
|
||||||
|
*/
|
||||||
|
encrypt(plaintext) {
|
||||||
|
if (!this._cipher) throw new Error('Encryption not initialized');
|
||||||
|
const input = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
|
||||||
|
return this._cipher.update(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a ciphertext message.
|
||||||
|
* @param {Buffer} ciphertext
|
||||||
|
* @returns {string} plaintext UTF-8 string
|
||||||
|
*/
|
||||||
|
decrypt(ciphertext) {
|
||||||
|
if (!this._decipher) throw new Error('Encryption not initialized');
|
||||||
|
return this._decipher.update(ciphertext).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} Whether encryption is active */
|
||||||
|
get enabled() {
|
||||||
|
return this._enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* In-memory ring buffer for Minecraft game events.
|
* In-memory ring buffer for Minecraft game events.
|
||||||
* Privacy-first: no disk persistence, lost on restart.
|
* Privacy-first: no disk persistence by default, lost on restart.
|
||||||
*/
|
* Capacity configurable via EVENT_BUFFER_SIZE env var.
|
||||||
export class EventStore {
|
*/
|
||||||
/** @param {number} capacity - Maximum events to retain */
|
export class EventStore {
|
||||||
constructor(capacity = 100) {
|
/** @param {number} capacity - Maximum events to retain */
|
||||||
this._capacity = capacity;
|
constructor(capacity) {
|
||||||
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
this._capacity = capacity ?? parseInt(process.env.EVENT_BUFFER_SIZE || '1000', 10);
|
||||||
this._events = [];
|
/** @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")
|
* Push a new event into the ring buffer.
|
||||||
* @param {object} data - Event payload
|
* @param {string} type - Event type (e.g. "PlayerMessage")
|
||||||
*/
|
* @param {object} data - Event payload
|
||||||
push(type, data) {
|
*/
|
||||||
this._events.push({
|
push(type, data) {
|
||||||
type,
|
this._events.push({
|
||||||
timestamp: new Date().toISOString(),
|
type,
|
||||||
data,
|
timestamp: new Date().toISOString(),
|
||||||
});
|
data,
|
||||||
|
});
|
||||||
// Trim to capacity
|
|
||||||
if (this._events.length > this._capacity) {
|
// Trim to capacity
|
||||||
this._events = this._events.slice(-this._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
|
* Get the most recent events.
|
||||||
* @returns {Array}
|
* @param {number} count - Number of events to return
|
||||||
*/
|
* @returns {Array}
|
||||||
getRecent(count = 20) {
|
*/
|
||||||
return this._events.slice(-count);
|
getRecent(count = 20) {
|
||||||
}
|
return this._events.slice(-count);
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Get recent events filtered by type.
|
/**
|
||||||
* @param {string} type - Event type to filter by
|
* Get recent events filtered by type.
|
||||||
* @param {number} count - Max number to return
|
* @param {string} type - Event type to filter by
|
||||||
* @returns {Array}
|
* @param {number} count - Max number to return
|
||||||
*/
|
* @returns {Array}
|
||||||
getByType(type, count = 20) {
|
*/
|
||||||
return this._events
|
getByType(type, count = 20) {
|
||||||
.filter((e) => e.type === type)
|
return this._events
|
||||||
.slice(-count);
|
.filter((e) => e.type === type)
|
||||||
}
|
.slice(-count);
|
||||||
|
}
|
||||||
/** @returns {number} Total events currently stored */
|
|
||||||
get size() {
|
/**
|
||||||
return this._events.length;
|
* Get recent events filtered by multiple types.
|
||||||
}
|
* @param {string[]} types - Event types to include
|
||||||
|
* @param {number} count - Max number to return
|
||||||
/** Clear all events */
|
* @returns {Array}
|
||||||
clear() {
|
*/
|
||||||
this._events = [];
|
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
396
src/grabcraft.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { log, logError } from './utils.js';
|
||||||
|
import { resolveBlock, formatBlock, getUnknownBlocks, clearUnknownBlocks } from './block-map.js';
|
||||||
|
|
||||||
|
const TAG = 'GrabCraft';
|
||||||
|
|
||||||
|
const GRABCRAFT_BASE = 'https://www.grabcraft.com';
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LRU cache for fetched blueprints.
|
||||||
|
*/
|
||||||
|
class LRUCache {
|
||||||
|
constructor(maxSize = 50, ttlMs = 3600000) {
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
this._ttlMs = ttlMs;
|
||||||
|
/** @type {Map<string, { data: any, timestamp: number }>} */
|
||||||
|
this._cache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
const entry = this._cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() - entry.timestamp > this._ttlMs) {
|
||||||
|
this._cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Move to end (most recently used)
|
||||||
|
this._cache.delete(key);
|
||||||
|
this._cache.set(key, entry);
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, data) {
|
||||||
|
this._cache.delete(key);
|
||||||
|
if (this._cache.size >= this._maxSize) {
|
||||||
|
// Delete oldest (first entry)
|
||||||
|
const firstKey = this._cache.keys().next().value;
|
||||||
|
this._cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
this._cache.set(key, { data, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blueprintCache = new LRUCache(50, 3600000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search GrabCraft for blueprints matching a query.
|
||||||
|
* @param {string} query - Search term
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @returns {Promise<{ results: Array<{ name: string, url: string, category: string, blocks: string }>, total: number, page: number }>}
|
||||||
|
*/
|
||||||
|
export async function searchBlueprints(query, page = 1) {
|
||||||
|
const searchUrl = `${GRABCRAFT_BASE}/search/${encodeURIComponent(query)}/${page}`;
|
||||||
|
log(TAG, `Searching: ${searchUrl}`);
|
||||||
|
|
||||||
|
const html = await fetchPage(searchUrl);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// GrabCraft search results are in div.browse-item or similar card elements
|
||||||
|
// Pattern: <a href="/minecraft/...">...<div class="title">Name</div>...
|
||||||
|
const itemRegex = /<a[^>]+href="(\/minecraft\/[^"]+)"[^>]*>[\s\S]*?<div[^>]*class="[^"]*browse-item-title[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = itemRegex.exec(html)) !== null) {
|
||||||
|
const url = GRABCRAFT_BASE + match[1];
|
||||||
|
const name = match[2].replace(/<[^>]+>/g, '').trim();
|
||||||
|
|
||||||
|
// Try to extract category and block count from surrounding context
|
||||||
|
const contextStart = Math.max(0, match.index - 500);
|
||||||
|
const contextEnd = Math.min(html.length, match.index + match[0].length + 500);
|
||||||
|
const context = html.substring(contextStart, contextEnd);
|
||||||
|
|
||||||
|
const categoryMatch = context.match(/category[^>]*>([^<]+)/i);
|
||||||
|
const blockMatch = context.match(/(\d+)\s*blocks?/i);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
category: categoryMatch ? categoryMatch[1].trim() : 'Unknown',
|
||||||
|
blocks: blockMatch ? blockMatch[1] : 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try alternate HTML structure
|
||||||
|
if (results.length === 0) {
|
||||||
|
const altRegex = /<a[^>]+href="(\/minecraft\/[^"]+)"[^>]*class="[^"]*"[^>]*>[\s\S]*?<[^>]+>([^<]{3,})<\/[^>]+>/gi;
|
||||||
|
while ((match = altRegex.exec(html)) !== null) {
|
||||||
|
const url = GRABCRAFT_BASE + match[1];
|
||||||
|
const name = match[2].replace(/<[^>]+>/g, '').trim();
|
||||||
|
if (name && name.length > 2 && !name.includes('{') && !name.includes('function')) {
|
||||||
|
results.push({ name, url, category: 'Unknown', blocks: 'Unknown' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get total count
|
||||||
|
const totalMatch = html.match(/(\d+)\s*results?/i);
|
||||||
|
const total = totalMatch ? parseInt(totalMatch[1], 10) : results.length;
|
||||||
|
|
||||||
|
log(TAG, `Found ${results.length} results (total: ${total})`);
|
||||||
|
return { results, total, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse a GrabCraft blueprint page.
|
||||||
|
* Extracts the embedded voxel data (myRenderObject), materials, and layer map.
|
||||||
|
* @param {string} url - Full GrabCraft blueprint URL
|
||||||
|
* @returns {Promise<object>} Parsed blueprint data
|
||||||
|
*/
|
||||||
|
export async function fetchBlueprint(url) {
|
||||||
|
// Check cache
|
||||||
|
const cached = blueprintCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
log(TAG, `Cache hit: ${url}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, `Fetching blueprint: ${url}`);
|
||||||
|
const html = await fetchPage(url);
|
||||||
|
|
||||||
|
// Extract the name from page title
|
||||||
|
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||||
|
const name = titleMatch
|
||||||
|
? titleMatch[1].replace(/\s*[-|].*$/, '').replace(/GrabCraft/i, '').trim()
|
||||||
|
: 'Unknown Blueprint';
|
||||||
|
|
||||||
|
// Extract render object (3D voxel data)
|
||||||
|
const voxels = extractRenderObject(html);
|
||||||
|
|
||||||
|
// Extract materials list
|
||||||
|
const materials = extractMaterials(html);
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||||
|
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||||
|
|
||||||
|
for (const voxel of voxels) {
|
||||||
|
minX = Math.min(minX, voxel.x);
|
||||||
|
minY = Math.min(minY, voxel.y);
|
||||||
|
minZ = Math.min(minZ, voxel.z);
|
||||||
|
maxX = Math.max(maxX, voxel.x);
|
||||||
|
maxY = Math.max(maxY, voxel.y);
|
||||||
|
maxZ = Math.max(maxZ, voxel.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = voxels.length > 0
|
||||||
|
? { width: maxX - minX + 1, height: maxY - minY + 1, depth: maxZ - minZ + 1 }
|
||||||
|
: { width: 0, height: 0, depth: 0 };
|
||||||
|
|
||||||
|
const blueprint = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
voxels,
|
||||||
|
materials,
|
||||||
|
dimensions,
|
||||||
|
totalBlocks: voxels.filter(v => v.matId !== '0').length,
|
||||||
|
origin: { x: minX, y: minY, z: minZ },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache it
|
||||||
|
blueprintCache.set(url, blueprint);
|
||||||
|
|
||||||
|
log(TAG, `Parsed "${name}": ${blueprint.totalBlocks} blocks, ${dimensions.width}x${dimensions.height}x${dimensions.depth}`);
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a blueprint to Bedrock setblock commands.
|
||||||
|
* @param {object} blueprint - Parsed blueprint from fetchBlueprint()
|
||||||
|
* @param {number} originX - World X coordinate for build origin
|
||||||
|
* @param {number} originY - World Y coordinate for build origin
|
||||||
|
* @param {number} originZ - World Z coordinate for build origin
|
||||||
|
* @returns {{ commands: string[], summary: object }}
|
||||||
|
*/
|
||||||
|
export function blueprintToCommands(blueprint, originX, originY, originZ) {
|
||||||
|
clearUnknownBlocks();
|
||||||
|
|
||||||
|
const { voxels, origin } = blueprint;
|
||||||
|
const commands = [];
|
||||||
|
const materialCounts = new Map();
|
||||||
|
let skippedAir = 0;
|
||||||
|
|
||||||
|
// Sort voxels bottom-up (y ascending) for structural integrity
|
||||||
|
const sorted = [...voxels].sort((a, b) => {
|
||||||
|
if (a.y !== b.y) return a.y - b.y;
|
||||||
|
if (a.z !== b.z) return a.z - b.z;
|
||||||
|
return a.x - b.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const voxel of sorted) {
|
||||||
|
// Skip air blocks
|
||||||
|
if (voxel.matId === '0' || voxel.matId === 'air') {
|
||||||
|
skippedAir++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveBlock(voxel.matId, voxel.matName);
|
||||||
|
const blockStr = formatBlock(resolved);
|
||||||
|
|
||||||
|
// Translate to world coordinates relative to build origin
|
||||||
|
const wx = originX + (voxel.x - origin.x);
|
||||||
|
const wy = originY + (voxel.y - origin.y);
|
||||||
|
const wz = originZ + (voxel.z - origin.z);
|
||||||
|
|
||||||
|
commands.push(`setblock ${wx} ${wy} ${wz} ${blockStr}`);
|
||||||
|
|
||||||
|
const matKey = resolved.name;
|
||||||
|
materialCounts.set(matKey, (materialCounts.get(matKey) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknowns = getUnknownBlocks();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
name: blueprint.name,
|
||||||
|
totalVoxels: voxels.length,
|
||||||
|
totalCommands: commands.length,
|
||||||
|
skippedAir,
|
||||||
|
dimensions: blueprint.dimensions,
|
||||||
|
materials: Object.fromEntries(materialCounts),
|
||||||
|
unmappedBlocks: unknowns.size > 0 ? Object.fromEntries(unknowns) : null,
|
||||||
|
buildOrigin: { x: originX, y: originY, z: originZ },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { commands, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract myRenderObject voxel data from the page HTML.
|
||||||
|
* GrabCraft embeds data like:
|
||||||
|
* myRenderObject[y][x][z] = { mat_id: "5:5", rgb: {...}, hex: "#...", ... }
|
||||||
|
*/
|
||||||
|
function extractRenderObject(html) {
|
||||||
|
const voxels = [];
|
||||||
|
|
||||||
|
// Pattern 1: myRenderObject[y][x][z] = {...}
|
||||||
|
const renderRegex = /myRenderObject\[(\d+)\]\[(\d+)\]\[(\d+)\]\s*=\s*(\{[^}]+\})/g;
|
||||||
|
let match;
|
||||||
|
while ((match = renderRegex.exec(html)) !== null) {
|
||||||
|
const y = parseInt(match[1], 10);
|
||||||
|
const x = parseInt(match[2], 10);
|
||||||
|
const z = parseInt(match[3], 10);
|
||||||
|
const objStr = match[4];
|
||||||
|
|
||||||
|
const matIdMatch = objStr.match(/mat_id\s*:\s*["']([^"']+)["']/);
|
||||||
|
const matId = matIdMatch ? matIdMatch[1] : '0';
|
||||||
|
|
||||||
|
const hexMatch = objStr.match(/hex\s*:\s*["']([^"']+)["']/);
|
||||||
|
const hex = hexMatch ? hexMatch[1] : null;
|
||||||
|
|
||||||
|
voxels.push({ x, y, z, matId, hex, matName: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: layerMap-based data
|
||||||
|
// layerMap[y] = [{x:..., z:..., mat_id:...}, ...]
|
||||||
|
if (voxels.length === 0) {
|
||||||
|
const layerRegex = /layerMap\[(\d+)\]\s*=\s*\[([\s\S]*?)\];/g;
|
||||||
|
while ((match = layerRegex.exec(html)) !== null) {
|
||||||
|
const y = parseInt(match[1], 10);
|
||||||
|
const arrayContent = match[2];
|
||||||
|
|
||||||
|
const blockRegex = /\{[^}]*x\s*:\s*(\d+)[^}]*z\s*:\s*(\d+)[^}]*mat_id\s*:\s*["']([^"']+)["'][^}]*\}/g;
|
||||||
|
let blockMatch;
|
||||||
|
while ((blockMatch = blockRegex.exec(arrayContent)) !== null) {
|
||||||
|
voxels.push({
|
||||||
|
x: parseInt(blockMatch[1], 10),
|
||||||
|
y,
|
||||||
|
z: parseInt(blockMatch[2], 10),
|
||||||
|
matId: blockMatch[3],
|
||||||
|
hex: null,
|
||||||
|
matName: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: JSON array of blocks
|
||||||
|
if (voxels.length === 0) {
|
||||||
|
const jsonRegex = /var\s+blocks?\s*=\s*(\[[\s\S]*?\]);/;
|
||||||
|
const jsonMatch = html.match(jsonRegex);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const blocks = JSON.parse(jsonMatch[1]);
|
||||||
|
for (const b of blocks) {
|
||||||
|
voxels.push({
|
||||||
|
x: b.x || 0,
|
||||||
|
y: b.y || 0,
|
||||||
|
z: b.z || 0,
|
||||||
|
matId: String(b.mat_id || b.id || '0'),
|
||||||
|
hex: b.hex || null,
|
||||||
|
matName: b.name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logError(TAG, 'Failed to parse blocks JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, `Extracted ${voxels.length} voxels from page`);
|
||||||
|
|
||||||
|
// Cross-reference with materials if names are missing
|
||||||
|
return voxels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract materials list from Highcharts series data.
|
||||||
|
* GrabCraft pages include chart data like:
|
||||||
|
* series: [{ name: "Stone", id: "1", data: [{y: 500}], ... }]
|
||||||
|
*/
|
||||||
|
function extractMaterials(html) {
|
||||||
|
const materials = [];
|
||||||
|
|
||||||
|
// Pattern: series data in Highcharts config
|
||||||
|
// Look for objects with id, name, and y (count) fields
|
||||||
|
const seriesRegex = /\{\s*(?:name|id)\s*:\s*["']([^"']+)["']\s*,\s*(?:id|name)\s*:\s*["']([^"']+)["'][^}]*y\s*:\s*(\d+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = seriesRegex.exec(html)) !== null) {
|
||||||
|
// Determine which is name vs id based on content
|
||||||
|
let id, name, count;
|
||||||
|
if (match[1].match(/^\d/)) {
|
||||||
|
id = match[1];
|
||||||
|
name = match[2];
|
||||||
|
} else {
|
||||||
|
name = match[1];
|
||||||
|
id = match[2];
|
||||||
|
}
|
||||||
|
count = parseInt(match[3], 10);
|
||||||
|
|
||||||
|
materials.push({ id, name, count });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: look for material list in a different format
|
||||||
|
if (materials.length === 0) {
|
||||||
|
const matRegex = /id\s*:\s*["'](\d+(?::\d+)?)["']\s*,\s*name\s*:\s*["']([^"']+)["']\s*,\s*y\s*:\s*(\d+)/g;
|
||||||
|
while ((match = matRegex.exec(html)) !== null) {
|
||||||
|
materials.push({
|
||||||
|
id: match[1],
|
||||||
|
name: match[2],
|
||||||
|
count: parseInt(match[3], 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, `Extracted ${materials.length} materials`);
|
||||||
|
return materials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page with standard headers.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {Promise<string>} HTML content
|
||||||
|
*/
|
||||||
|
async function fetchPage(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available GrabCraft categories.
|
||||||
|
* @returns {Array<{ name: string, slug: string }>}
|
||||||
|
*/
|
||||||
|
export function getCategories() {
|
||||||
|
return [
|
||||||
|
{ name: 'Houses & Mansions', slug: 'houses' },
|
||||||
|
{ name: 'Castles & Fortresses', slug: 'castles' },
|
||||||
|
{ name: 'Medieval Buildings', slug: 'medieval' },
|
||||||
|
{ name: 'Modern Buildings', slug: 'modern' },
|
||||||
|
{ name: 'Towers', slug: 'towers' },
|
||||||
|
{ name: 'Churches & Temples', slug: 'churches' },
|
||||||
|
{ name: 'Ships & Boats', slug: 'ships' },
|
||||||
|
{ name: 'Bridges', slug: 'bridges' },
|
||||||
|
{ name: 'Farms', slug: 'farms' },
|
||||||
|
{ name: 'Statues & Sculptures', slug: 'statues' },
|
||||||
|
{ name: 'Pixel Art', slug: 'pixel-art' },
|
||||||
|
{ name: 'Redstone Devices', slug: 'redstone' },
|
||||||
|
{ name: 'Gardens & Parks', slug: 'gardens' },
|
||||||
|
{ name: 'Furniture & Decor', slug: 'furniture' },
|
||||||
|
{ name: 'Vehicles', slug: 'vehicles' },
|
||||||
|
{ name: 'Fantasy', slug: 'fantasy' },
|
||||||
|
{ name: 'Sci-Fi', slug: 'sci-fi' },
|
||||||
|
{ name: 'Animals', slug: 'animals' },
|
||||||
|
{ name: 'Trees & Nature', slug: 'trees' },
|
||||||
|
{ name: 'Underground', slug: 'underground' },
|
||||||
|
];
|
||||||
|
}
|
||||||
72
src/index.js
72
src/index.js
@@ -1,36 +1,36 @@
|
|||||||
import { BedrockWebSocket } from './bedrock-ws.js';
|
import { BedrockWebSocket } from './bedrock-ws.js';
|
||||||
import { startMcpServer } from './mcp-server.js';
|
import { startMcpServer } from './mcp-server.js';
|
||||||
import { log } from './utils.js';
|
import { log } from './utils.js';
|
||||||
|
|
||||||
const TAG = 'Main';
|
const TAG = 'Main';
|
||||||
|
|
||||||
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
|
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
|
||||||
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
|
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
|
||||||
|
|
||||||
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
|
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
|
||||||
|
|
||||||
// Start the Bedrock WebSocket server
|
// Start the Bedrock WebSocket server
|
||||||
const bedrock = new BedrockWebSocket({ port: WS_PORT });
|
const bedrock = new BedrockWebSocket({ port: WS_PORT });
|
||||||
bedrock.start();
|
bedrock.start();
|
||||||
|
|
||||||
// Start the MCP server (Streamable HTTP transport)
|
// Start the MCP server (Streamable HTTP transport)
|
||||||
startMcpServer(bedrock, MCP_PORT);
|
startMcpServer(bedrock, MCP_PORT);
|
||||||
|
|
||||||
log(TAG, '');
|
log(TAG, '');
|
||||||
log(TAG, '=== READY ===');
|
log(TAG, '=== READY ===');
|
||||||
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
|
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, `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, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
|
||||||
log(TAG, '');
|
log(TAG, '');
|
||||||
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
|
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
|
||||||
log(TAG, '');
|
log(TAG, '');
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
log(TAG, `${signal} received, shutting down...`);
|
log(TAG, `${signal} received, shutting down...`);
|
||||||
bedrock.stop();
|
bedrock.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
|||||||
1286
src/mcp-server.js
1286
src/mcp-server.js
File diff suppressed because it is too large
Load Diff
188
src/utils.js
188
src/utils.js
@@ -1,80 +1,108 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Bedrock WebSocket subscribe message envelope.
|
* Create a Bedrock WebSocket subscribe message envelope.
|
||||||
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
|
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
|
||||||
* @returns {string} JSON string ready to send over WS
|
* @returns {string} JSON string ready to send over WS
|
||||||
*/
|
*/
|
||||||
export function createSubscribeMessage(eventName) {
|
export function createSubscribeMessage(eventName) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
header: {
|
header: {
|
||||||
version: 1,
|
version: 1,
|
||||||
requestId: randomUUID(),
|
requestId: randomUUID(),
|
||||||
messageType: 'commandRequest',
|
messageType: 'commandRequest',
|
||||||
messagePurpose: 'subscribe',
|
messagePurpose: 'subscribe',
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
eventName,
|
eventName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Bedrock WebSocket command message envelope.
|
* Create a Bedrock WebSocket command message envelope.
|
||||||
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
|
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
|
||||||
* @returns {{ id: string, message: string }} request ID and JSON string
|
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||||
*/
|
*/
|
||||||
export function createCommandMessage(commandLine) {
|
export function createCommandMessage(commandLine) {
|
||||||
// Strip leading slash if present
|
// Strip leading slash if present
|
||||||
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
|
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
|
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
header: {
|
header: {
|
||||||
version: 1,
|
version: 1,
|
||||||
requestId,
|
requestId,
|
||||||
messageType: 'commandRequest',
|
messageType: 'commandRequest',
|
||||||
messagePurpose: 'commandRequest',
|
messagePurpose: 'commandRequest',
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
version: 1,
|
version: 1,
|
||||||
commandLine: cmd,
|
commandLine: cmd,
|
||||||
origin: {
|
origin: {
|
||||||
type: 'player',
|
type: 'player',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: requestId, message };
|
return { id: requestId, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip Minecraft formatting codes (section sign + character).
|
* Create a Bedrock WebSocket enableencryption command message.
|
||||||
* @param {string} text
|
* @param {string} publicKeyBase64 - Server's base64-encoded public key
|
||||||
* @returns {string}
|
* @param {string} saltBase64 - Base64-encoded 16-byte salt
|
||||||
*/
|
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||||
export function sanitize(text) {
|
*/
|
||||||
if (!text) return '';
|
export function createEnableEncryptionMessage(publicKeyBase64, saltBase64) {
|
||||||
// Remove section sign formatting codes like §a, §l, §r etc.
|
const requestId = randomUUID();
|
||||||
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
|
|
||||||
}
|
const message = JSON.stringify({
|
||||||
|
header: {
|
||||||
/**
|
version: 1,
|
||||||
* Timestamped log helper.
|
requestId,
|
||||||
* @param {string} tag - Module tag
|
messageType: 'commandRequest',
|
||||||
* @param {...any} args - Log arguments
|
messagePurpose: 'commandRequest',
|
||||||
*/
|
},
|
||||||
export function log(tag, ...args) {
|
body: {
|
||||||
const ts = new Date().toISOString();
|
version: 1,
|
||||||
console.log(`[${ts}] [${tag}]`, ...args);
|
commandLine: `enableencryption "${publicKeyBase64}" "${saltBase64}"`,
|
||||||
}
|
origin: {
|
||||||
|
type: 'player',
|
||||||
/**
|
},
|
||||||
* Timestamped error log helper.
|
},
|
||||||
* @param {string} tag - Module tag
|
});
|
||||||
* @param {...any} args - Log arguments
|
|
||||||
*/
|
return { id: requestId, message };
|
||||||
export function logError(tag, ...args) {
|
}
|
||||||
const ts = new Date().toISOString();
|
|
||||||
console.error(`[${ts}] [${tag}]`, ...args);
|
/**
|
||||||
}
|
* 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user