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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
password: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
port: 22
|
||||
script: |
|
||||
set -e
|
||||
APP_DIR="$HOME/mc-ai-bridge"
|
||||
|
||||
# First run: clone. Subsequent: pull.
|
||||
if [ ! -d "$APP_DIR/.git" ]; then
|
||||
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
|
||||
else
|
||||
cd "$APP_DIR"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Build and deploy
|
||||
docker compose down --remove-orphans || true
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify container is running
|
||||
sleep 5
|
||||
docker compose ps
|
||||
echo "--- Health check ---"
|
||||
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"
|
||||
name: Deploy to Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
password: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
port: 22
|
||||
script: |
|
||||
set -e
|
||||
APP_DIR="$HOME/mc-ai-bridge"
|
||||
|
||||
# First run: clone. Subsequent: pull.
|
||||
if [ ! -d "$APP_DIR/.git" ]; then
|
||||
git clone https://git.silverlabs.uk/SilverLABS/mc-ai-bridge.git "$APP_DIR"
|
||||
else
|
||||
cd "$APP_DIR"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Build and deploy
|
||||
docker compose down --remove-orphans || true
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Verify container is running
|
||||
sleep 5
|
||||
docker compose ps
|
||||
echo "--- Health check ---"
|
||||
curl -sf http://localhost:3002/health || echo "Health check not yet available (container may still be starting)"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
28
Dockerfile
28
Dockerfile
@@ -1,14 +1,14 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --production 2>/dev/null || npm install --production
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3001 3002
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --production 2>/dev/null || npm install --production
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3001 3002
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
services:
|
||||
mc-ai-bridge:
|
||||
build: .
|
||||
container_name: mc-ai-bridge
|
||||
ports:
|
||||
- "3001:3001" # Minecraft WebSocket
|
||||
- "3002:3002" # MCP SSE transport
|
||||
environment:
|
||||
- WS_PORT=3001
|
||||
- MCP_PORT=3002
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
services:
|
||||
mc-ai-bridge:
|
||||
build: .
|
||||
container_name: mc-ai-bridge
|
||||
ports:
|
||||
- "3001:3001" # Minecraft WebSocket
|
||||
- "3002:3002" # MCP SSE transport
|
||||
environment:
|
||||
- WS_PORT=3001
|
||||
- MCP_PORT=3002
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
|
||||
3104
package-lock.json
generated
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",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
{
|
||||
"name": "mc-ai-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -1,224 +1,382 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createSubscribeMessage, createCommandMessage, sanitize, log, logError } from './utils.js';
|
||||
import { EventStore } from './event-store.js';
|
||||
import { CommandQueue } from './command-queue.js';
|
||||
|
||||
const TAG = 'BedrockWS';
|
||||
|
||||
/**
|
||||
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
||||
* Only one Minecraft client is supported at a time.
|
||||
*/
|
||||
export class BedrockWebSocket {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.port - WebSocket listen port (default 3001)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.port = opts.port ?? 3001;
|
||||
this.events = new EventStore(100);
|
||||
this.commandQueue = new CommandQueue();
|
||||
|
||||
/** @type {import('ws').WebSocket | null} */
|
||||
this._ws = null;
|
||||
this._wss = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions = new Set();
|
||||
}
|
||||
|
||||
/** Start the WebSocket server */
|
||||
start() {
|
||||
this._wss = new WebSocketServer({ port: this.port });
|
||||
|
||||
this._wss.on('listening', () => {
|
||||
log(TAG, `WebSocket server listening on port ${this.port}`);
|
||||
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
|
||||
});
|
||||
|
||||
this._wss.on('connection', (ws) => {
|
||||
// Only allow one connection at a time
|
||||
if (this._ws) {
|
||||
log(TAG, 'Rejecting new connection - already have an active client');
|
||||
ws.close(1000, 'Only one Minecraft client supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws = ws;
|
||||
this._connectedAt = new Date();
|
||||
log(TAG, 'Minecraft client connected!');
|
||||
|
||||
// Wire up command queue to send over this socket
|
||||
this.commandQueue.setSendFunction((id, message) => {
|
||||
if (this._ws && this._ws.readyState === 1) {
|
||||
this._ws.send(message);
|
||||
} else {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-subscribe to key events
|
||||
this._autoSubscribe();
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw.toString());
|
||||
this._handleMessage(data);
|
||||
} catch (err) {
|
||||
logError(TAG, 'Failed to parse message:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
|
||||
this._ws = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions.clear();
|
||||
this.commandQueue.setSendFunction(null);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logError(TAG, 'WebSocket error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
this._wss.on('error', (err) => {
|
||||
logError(TAG, 'Server error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Subscribe to default event types */
|
||||
_autoSubscribe() {
|
||||
const defaultEvents = ['PlayerMessage'];
|
||||
for (const eventName of defaultEvents) {
|
||||
this.subscribe(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a Bedrock event type.
|
||||
* @param {string} eventName
|
||||
*/
|
||||
subscribe(eventName) {
|
||||
if (!this._ws || this._ws.readyState !== 1) {
|
||||
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._subscriptions.has(eventName)) {
|
||||
log(TAG, `Already subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._ws.send(createSubscribeMessage(eventName));
|
||||
this._subscriptions.add(eventName);
|
||||
log(TAG, `Subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message from Bedrock.
|
||||
* @param {object} data - Parsed JSON message
|
||||
*/
|
||||
_handleMessage(data) {
|
||||
const purpose = data?.header?.messagePurpose;
|
||||
|
||||
if (purpose === 'commandResponse') {
|
||||
// Response to a command we sent
|
||||
const requestId = data.header.requestId;
|
||||
this.commandQueue.handleResponse(requestId, data.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purpose === 'event') {
|
||||
const eventName = data.header.eventName;
|
||||
const body = data.body || {};
|
||||
|
||||
// Filter bot's own messages to prevent echo loops
|
||||
if (eventName === 'PlayerMessage') {
|
||||
const sender = body.sender || '';
|
||||
const message = sanitize(body.message || '');
|
||||
const type = body.type || 'chat';
|
||||
|
||||
// Skip messages from external sources (commands, say, tell from server)
|
||||
if (type !== 'chat' || sender === 'External' || sender === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track player name from first chat message
|
||||
if (!this._playerName && sender) {
|
||||
this._playerName = sender;
|
||||
log(TAG, `Player identified: ${this._playerName}`);
|
||||
}
|
||||
|
||||
this.events.push(eventName, {
|
||||
sender,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
|
||||
log(TAG, `[Chat] <${sender}> ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store all other events
|
||||
this.events.push(eventName, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to Minecraft.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64"
|
||||
* @returns {Promise<object>} Command response
|
||||
*/
|
||||
async sendCommand(commandLine) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const { id, message } = createCommandMessage(commandLine);
|
||||
return this.commandQueue.enqueue(id, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of commands (for building).
|
||||
* @param {string[]} commandLines
|
||||
* @returns {Promise<object>} Batch result
|
||||
*/
|
||||
async sendBatch(commandLines) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const commands = commandLines.map((line) => createCommandMessage(line));
|
||||
return this.commandQueue.enqueueBatch(commands);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether a Minecraft client is connected */
|
||||
isConnected() {
|
||||
return this._ws !== null && this._ws.readyState === 1;
|
||||
}
|
||||
|
||||
/** @returns {object} Status information */
|
||||
getStatus() {
|
||||
return {
|
||||
connected: this.isConnected(),
|
||||
playerName: this._playerName,
|
||||
connectedAt: this._connectedAt?.toISOString() || null,
|
||||
subscriptions: [...this._subscriptions],
|
||||
eventCount: this.events.size,
|
||||
...this.commandQueue.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shut down the server */
|
||||
stop() {
|
||||
this.commandQueue.destroy();
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
}
|
||||
if (this._wss) {
|
||||
this._wss.close();
|
||||
}
|
||||
log(TAG, 'Server stopped');
|
||||
}
|
||||
}
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createSubscribeMessage, createCommandMessage, createEnableEncryptionMessage, sanitize, log, logError } from './utils.js';
|
||||
import { EventStore } from './event-store.js';
|
||||
import { CommandQueue } from './command-queue.js';
|
||||
import { ServerEncryption } from './encryption.js';
|
||||
|
||||
const TAG = 'BedrockWS';
|
||||
|
||||
/**
|
||||
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
||||
* Only one Minecraft client is supported at a time.
|
||||
* Supports Bedrock's application-level encryption handshake.
|
||||
*/
|
||||
export class BedrockWebSocket {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.port - WebSocket listen port (default 3001)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.port = opts.port ?? 3001;
|
||||
this.events = new EventStore();
|
||||
this.commandQueue = new CommandQueue();
|
||||
|
||||
/** @type {import('ws').WebSocket | null} */
|
||||
this._ws = null;
|
||||
this._wss = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions = new Set();
|
||||
|
||||
// Encryption state
|
||||
/** @type {ServerEncryption | null} */
|
||||
this._encryption = null;
|
||||
this._pendingEncryption = false;
|
||||
this._encryptionRequestId = null;
|
||||
}
|
||||
|
||||
/** Start the WebSocket server */
|
||||
start() {
|
||||
this._wss = new WebSocketServer({
|
||||
port: this.port,
|
||||
handleProtocols: (protocols) => {
|
||||
// Accept Bedrock's encryption subprotocol if offered
|
||||
if (protocols.has('com.microsoft.minecraft.wsencrypt')) {
|
||||
return 'com.microsoft.minecraft.wsencrypt';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
this._wss.on('listening', () => {
|
||||
log(TAG, `WebSocket server listening on port ${this.port}`);
|
||||
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
|
||||
});
|
||||
|
||||
this._wss.on('connection', (ws) => {
|
||||
// Only allow one connection at a time
|
||||
if (this._ws) {
|
||||
log(TAG, 'Rejecting new connection - already have an active client');
|
||||
ws.close(1000, 'Only one Minecraft client supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws = ws;
|
||||
this._connectedAt = new Date();
|
||||
log(TAG, 'Minecraft client connected!');
|
||||
|
||||
// Start encryption handshake BEFORE wiring up command queue
|
||||
this._beginEncryptionHandshake();
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (this._encryption && this._encryption.enabled) {
|
||||
// All messages after handshake are encrypted
|
||||
const buf = typeof raw === 'string' ? Buffer.from(raw) : raw;
|
||||
const plaintext = this._encryption.decrypt(buf);
|
||||
data = JSON.parse(plaintext);
|
||||
} else {
|
||||
// Pre-encryption: plaintext JSON
|
||||
data = JSON.parse(raw.toString());
|
||||
}
|
||||
|
||||
this._handleMessage(data);
|
||||
} catch (err) {
|
||||
logError(TAG, 'Failed to parse message:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
|
||||
this._ws = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions.clear();
|
||||
this._encryption = null;
|
||||
this._pendingEncryption = false;
|
||||
this._encryptionRequestId = null;
|
||||
this.commandQueue.setSendFunction(null);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logError(TAG, 'WebSocket error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
this._wss.on('error', (err) => {
|
||||
logError(TAG, 'Server error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Initiate the Bedrock encryption handshake */
|
||||
_beginEncryptionHandshake() {
|
||||
this._encryption = new ServerEncryption();
|
||||
this._pendingEncryption = true;
|
||||
|
||||
const { publicKey, salt } = this._encryption.getKeyExchangeParams();
|
||||
const { id, message } = createEnableEncryptionMessage(publicKey, salt);
|
||||
this._encryptionRequestId = id;
|
||||
|
||||
log(TAG, 'Sending enableencryption handshake...');
|
||||
|
||||
// Send plaintext — this is the last unencrypted message from server
|
||||
if (this._ws && this._ws.readyState === 1) {
|
||||
this._ws.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called after encryption handshake completes to wire up normal operation */
|
||||
_onEncryptionReady() {
|
||||
log(TAG, 'Encryption active — wiring command queue and auto-subscribing');
|
||||
|
||||
// Now wire up command queue to send through encryption
|
||||
this.commandQueue.setSendFunction((id, message) => {
|
||||
if (this._ws && this._ws.readyState === 1) {
|
||||
if (this._encryption && this._encryption.enabled) {
|
||||
this._ws.send(this._encryption.encrypt(message));
|
||||
} else {
|
||||
this._ws.send(message);
|
||||
}
|
||||
} else {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-subscribe to key events
|
||||
this._autoSubscribe();
|
||||
}
|
||||
|
||||
/** Subscribe to default event types */
|
||||
_autoSubscribe() {
|
||||
const defaultEvents = ['PlayerMessage'];
|
||||
for (const eventName of defaultEvents) {
|
||||
this.subscribe(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a Bedrock event type.
|
||||
* @param {string} eventName
|
||||
*/
|
||||
subscribe(eventName) {
|
||||
if (!this._ws || this._ws.readyState !== 1) {
|
||||
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._subscriptions.has(eventName)) {
|
||||
log(TAG, `Already subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const msg = createSubscribeMessage(eventName);
|
||||
if (this._encryption && this._encryption.enabled) {
|
||||
this._ws.send(this._encryption.encrypt(msg));
|
||||
} else {
|
||||
this._ws.send(msg);
|
||||
}
|
||||
this._subscriptions.add(eventName);
|
||||
log(TAG, `Subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message from Bedrock.
|
||||
* @param {object} data - Parsed JSON message
|
||||
*/
|
||||
_handleMessage(data) {
|
||||
const purpose = data?.header?.messagePurpose;
|
||||
|
||||
// Intercept encryption handshake response
|
||||
if (this._pendingEncryption && purpose === 'commandResponse') {
|
||||
const requestId = data.header.requestId;
|
||||
if (requestId === this._encryptionRequestId) {
|
||||
this._pendingEncryption = false;
|
||||
this._encryptionRequestId = null;
|
||||
|
||||
const publicKey = data.body?.publicKey;
|
||||
if (publicKey) {
|
||||
try {
|
||||
this._encryption.completeKeyExchange(publicKey);
|
||||
this._onEncryptionReady();
|
||||
} catch (err) {
|
||||
logError(TAG, 'Encryption key exchange failed:', err.message);
|
||||
// Fall back to unencrypted mode
|
||||
this._encryption = null;
|
||||
this._onEncryptionReady();
|
||||
}
|
||||
} else {
|
||||
logError(TAG, 'No public key in encryption response — falling back to plaintext');
|
||||
this._encryption = null;
|
||||
this._onEncryptionReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (purpose === 'commandResponse') {
|
||||
// Response to a command we sent
|
||||
const requestId = data.header.requestId;
|
||||
this.commandQueue.handleResponse(requestId, data.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purpose === 'event') {
|
||||
const eventName = data.header.eventName;
|
||||
const body = data.body || {};
|
||||
|
||||
// Filter bot's own messages to prevent echo loops
|
||||
if (eventName === 'PlayerMessage') {
|
||||
const sender = body.sender || '';
|
||||
const message = sanitize(body.message || '');
|
||||
const type = body.type || 'chat';
|
||||
|
||||
// Skip messages from external sources (commands, say, tell from server)
|
||||
if (type !== 'chat' || sender === 'External' || sender === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track player name from first chat message
|
||||
if (!this._playerName && sender) {
|
||||
this._playerName = sender;
|
||||
log(TAG, `Player identified: ${this._playerName}`);
|
||||
}
|
||||
|
||||
this.events.push(eventName, {
|
||||
sender,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
|
||||
log(TAG, `[Chat] <${sender}> ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store all other events
|
||||
this.events.push(eventName, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to Minecraft.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64"
|
||||
* @returns {Promise<object>} Command response
|
||||
*/
|
||||
async sendCommand(commandLine) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const { id, message } = createCommandMessage(commandLine);
|
||||
return this.commandQueue.enqueue(id, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of commands (for building).
|
||||
* @param {string[]} commandLines
|
||||
* @returns {Promise<object>} Batch result
|
||||
*/
|
||||
async sendBatch(commandLines) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const commands = commandLines.map((line) => createCommandMessage(line));
|
||||
return this.commandQueue.enqueueBatch(commands);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether a Minecraft client is connected */
|
||||
isConnected() {
|
||||
return this._ws !== null && this._ws.readyState === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the player's current position using /querytarget @s.
|
||||
* @returns {Promise<{ x: number, y: number, z: number, rx: number, ry: number, dimension: number }>}
|
||||
*/
|
||||
async getPlayerPosition() {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
|
||||
const response = await this.sendCommand('querytarget @s');
|
||||
const details = response?.details;
|
||||
|
||||
if (!details) {
|
||||
throw new Error('No response from querytarget — is a player connected?');
|
||||
}
|
||||
|
||||
// querytarget returns a JSON string in details field
|
||||
try {
|
||||
let parsed;
|
||||
if (typeof details === 'string') {
|
||||
// Response is a JSON array string like: [{"uniqueId":...,"position":{...},...}]
|
||||
parsed = JSON.parse(details);
|
||||
} else {
|
||||
parsed = details;
|
||||
}
|
||||
|
||||
const target = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (!target || !target.position) {
|
||||
throw new Error('Invalid querytarget response format');
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.floor(target.position.x),
|
||||
y: Math.floor(target.position.y),
|
||||
z: Math.floor(target.position.z),
|
||||
rx: target.yRot ?? 0,
|
||||
ry: target.xRot ?? 0,
|
||||
dimension: target.dimension ?? 0,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.message.includes('Invalid querytarget')) throw err;
|
||||
throw new Error(`Failed to parse position data: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for a specific block at coordinates.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
* @param {string} [blockId] - Optional block ID to test for
|
||||
* @returns {Promise<object>} Test result
|
||||
*/
|
||||
async testForBlock(x, y, z, blockId) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
|
||||
const cmd = blockId
|
||||
? `testforblock ${x} ${y} ${z} ${blockId}`
|
||||
: `testforblock ${x} ${y} ${z}`;
|
||||
|
||||
return this.sendCommand(cmd);
|
||||
}
|
||||
|
||||
/** @returns {object} Status information */
|
||||
getStatus() {
|
||||
return {
|
||||
connected: this.isConnected(),
|
||||
encrypted: this._encryption?.enabled ?? false,
|
||||
playerName: this._playerName,
|
||||
connectedAt: this._connectedAt?.toISOString() || null,
|
||||
subscriptions: [...this._subscriptions],
|
||||
eventCount: this.events.size,
|
||||
...this.commandQueue.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shut down the server */
|
||||
stop() {
|
||||
this.commandQueue.destroy();
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
}
|
||||
if (this._wss) {
|
||||
this._wss.close();
|
||||
}
|
||||
log(TAG, 'Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
692
src/block-map.js
Normal file
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';
|
||||
|
||||
const TAG = 'CommandQueue';
|
||||
|
||||
/**
|
||||
* Rate-limited command dispatcher for Minecraft Bedrock.
|
||||
* Bedrock has a hard limit of ~100 in-flight commands.
|
||||
* We cap at 80 and throttle at 50ms between commands.
|
||||
*/
|
||||
export class CommandQueue {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
|
||||
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
||||
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
|
||||
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.maxInFlight = opts.maxInFlight ?? 80;
|
||||
this.throttleMs = opts.throttleMs ?? 50;
|
||||
this.batchSize = opts.batchSize ?? 20;
|
||||
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
||||
|
||||
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
||||
this._pending = new Map();
|
||||
this._queue = [];
|
||||
this._processing = false;
|
||||
this._sendFn = null;
|
||||
this._totalSent = 0;
|
||||
this._totalCompleted = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the function used to actually send a command over WebSocket.
|
||||
* @param {(id: string, message: string) => void} fn
|
||||
*/
|
||||
setSendFunction(fn) {
|
||||
this._sendFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a command response comes back from Bedrock.
|
||||
* @param {string} requestId
|
||||
* @param {object} response
|
||||
*/
|
||||
handleResponse(requestId, response) {
|
||||
const entry = this._pending.get(requestId);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this._pending.delete(requestId);
|
||||
this._totalCompleted++;
|
||||
entry.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single command for dispatch.
|
||||
* @param {string} id - Request UUID
|
||||
* @param {string} message - Serialized WS message
|
||||
* @param {number} timeoutMs - Per-command timeout (default 10s)
|
||||
* @returns {Promise<object>} Resolves with Bedrock response
|
||||
*/
|
||||
enqueue(id, message, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push({ id, message, resolve, reject, timeoutMs });
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a batch of commands (for building).
|
||||
* Sends in groups of batchSize with batchDelayMs between groups.
|
||||
* @param {Array<{id: string, message: string}>} commands
|
||||
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
|
||||
*/
|
||||
async enqueueBatch(commands) {
|
||||
const results = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||
const batch = commands.slice(i, i + this.batchSize);
|
||||
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
succeeded++;
|
||||
results.push(result.value);
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ error: result.reason?.message || 'unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between batches (except after the last one)
|
||||
if (i + this.batchSize < commands.length) {
|
||||
await this._delay(this.batchDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return { total: commands.length, succeeded, failed, results };
|
||||
}
|
||||
|
||||
/** Process queued commands respecting rate limits */
|
||||
async _processQueue() {
|
||||
if (this._processing) return;
|
||||
this._processing = true;
|
||||
|
||||
while (this._queue.length > 0) {
|
||||
// Wait if at capacity
|
||||
if (this._pending.size >= this.maxInFlight) {
|
||||
await this._delay(this.throttleMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = this._queue.shift();
|
||||
if (!item) break;
|
||||
|
||||
if (!this._sendFn) {
|
||||
item.reject(new Error('No WebSocket connection'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
const entry = this._pending.get(item.id);
|
||||
if (entry) {
|
||||
this._pending.delete(item.id);
|
||||
entry.reject(new Error('Command timed out'));
|
||||
}
|
||||
}, item.timeoutMs);
|
||||
|
||||
this._pending.set(item.id, {
|
||||
resolve: item.resolve,
|
||||
reject: item.reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
this._sendFn(item.id, item.message);
|
||||
this._totalSent++;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
this._pending.delete(item.id);
|
||||
item.reject(err);
|
||||
}
|
||||
|
||||
// Throttle between sends
|
||||
await this._delay(this.throttleMs);
|
||||
}
|
||||
|
||||
this._processing = false;
|
||||
}
|
||||
|
||||
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
|
||||
getStatus() {
|
||||
return {
|
||||
queueSize: this._queue.length,
|
||||
inFlight: this._pending.size,
|
||||
totalSent: this._totalSent,
|
||||
totalCompleted: this._totalCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
_delay(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Clean up all pending timeouts */
|
||||
destroy() {
|
||||
for (const entry of this._pending.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error('Queue destroyed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this._queue = [];
|
||||
}
|
||||
}
|
||||
import { log, logError } from './utils.js';
|
||||
|
||||
const TAG = 'CommandQueue';
|
||||
|
||||
/**
|
||||
* Rate-limited command dispatcher for Minecraft Bedrock.
|
||||
* Bedrock has a hard limit of ~100 in-flight commands.
|
||||
* We cap at 80 and throttle at 50ms between commands.
|
||||
*/
|
||||
export class CommandQueue {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
|
||||
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
||||
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
|
||||
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
||||
* @param {number} opts.maxBuildCommands - Max commands per build (default from env or 5000)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.maxInFlight = opts.maxInFlight ?? 80;
|
||||
this.throttleMs = opts.throttleMs ?? 50;
|
||||
this.batchSize = opts.batchSize ?? 20;
|
||||
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
||||
this.maxBuildCommands = opts.maxBuildCommands ?? parseInt(process.env.MAX_BUILD_COMMANDS || '5000', 10);
|
||||
|
||||
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
||||
this._pending = new Map();
|
||||
this._queue = [];
|
||||
this._processing = false;
|
||||
this._sendFn = null;
|
||||
this._totalSent = 0;
|
||||
this._totalCompleted = 0;
|
||||
this._cancelBuild = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the function used to actually send a command over WebSocket.
|
||||
* @param {(id: string, message: string) => void} fn
|
||||
*/
|
||||
setSendFunction(fn) {
|
||||
this._sendFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a command response comes back from Bedrock.
|
||||
* @param {string} requestId
|
||||
* @param {object} response
|
||||
*/
|
||||
handleResponse(requestId, response) {
|
||||
const entry = this._pending.get(requestId);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this._pending.delete(requestId);
|
||||
this._totalCompleted++;
|
||||
entry.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single command for dispatch.
|
||||
* @param {string} id - Request UUID
|
||||
* @param {string} message - Serialized WS message
|
||||
* @param {number} timeoutMs - Per-command timeout (default 10s)
|
||||
* @returns {Promise<object>} Resolves with Bedrock response
|
||||
*/
|
||||
enqueue(id, message, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push({ id, message, resolve, reject, timeoutMs });
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a batch of commands (for building).
|
||||
* Sends in groups of batchSize with batchDelayMs between groups.
|
||||
* @param {Array<{id: string, message: string}>} commands
|
||||
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
|
||||
*/
|
||||
async enqueueBatch(commands) {
|
||||
const results = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||
const batch = commands.slice(i, i + this.batchSize);
|
||||
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
succeeded++;
|
||||
results.push(result.value);
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ error: result.reason?.message || 'unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between batches (except after the last one)
|
||||
if (i + this.batchSize < commands.length) {
|
||||
await this._delay(this.batchDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return { total: commands.length, succeeded, failed, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a batch with progress reporting.
|
||||
* Calls progressFn with status updates between layer batches.
|
||||
* @param {Array<{id: string, message: string}>} commands
|
||||
* @param {(progress: { completed: number, total: number, percent: number }) => void} [progressFn]
|
||||
* @returns {Promise<{total: number, succeeded: number, failed: number, cancelled: boolean, results: Array}>}
|
||||
*/
|
||||
async enqueueBatchWithProgress(commands, progressFn) {
|
||||
this._cancelBuild = false;
|
||||
const results = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||
// Check cancellation
|
||||
if (this._cancelBuild) {
|
||||
log(TAG, `Build cancelled at ${i}/${commands.length} commands`);
|
||||
return { total: commands.length, succeeded, failed, cancelled: true, results };
|
||||
}
|
||||
|
||||
const batch = commands.slice(i, i + this.batchSize);
|
||||
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
succeeded++;
|
||||
results.push(result.value);
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ error: result.reason?.message || 'unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Report progress
|
||||
const completed = i + batch.length;
|
||||
const percent = Math.round((completed / commands.length) * 100);
|
||||
if (progressFn) {
|
||||
progressFn({ completed, total: commands.length, percent });
|
||||
}
|
||||
|
||||
// Delay between batches (except after the last one)
|
||||
if (i + this.batchSize < commands.length) {
|
||||
await this._delay(this.batchDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return { total: commands.length, succeeded, failed, cancelled: false, results };
|
||||
}
|
||||
|
||||
/** Cancel an in-progress build */
|
||||
cancelBuild() {
|
||||
this._cancelBuild = true;
|
||||
}
|
||||
|
||||
/** Process queued commands respecting rate limits */
|
||||
async _processQueue() {
|
||||
if (this._processing) return;
|
||||
this._processing = true;
|
||||
|
||||
while (this._queue.length > 0) {
|
||||
// Wait if at capacity
|
||||
if (this._pending.size >= this.maxInFlight) {
|
||||
await this._delay(this.throttleMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = this._queue.shift();
|
||||
if (!item) break;
|
||||
|
||||
if (!this._sendFn) {
|
||||
item.reject(new Error('No WebSocket connection'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
const entry = this._pending.get(item.id);
|
||||
if (entry) {
|
||||
this._pending.delete(item.id);
|
||||
entry.reject(new Error('Command timed out'));
|
||||
}
|
||||
}, item.timeoutMs);
|
||||
|
||||
this._pending.set(item.id, {
|
||||
resolve: item.resolve,
|
||||
reject: item.reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
this._sendFn(item.id, item.message);
|
||||
this._totalSent++;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
this._pending.delete(item.id);
|
||||
item.reject(err);
|
||||
}
|
||||
|
||||
// Throttle between sends
|
||||
await this._delay(this.throttleMs);
|
||||
}
|
||||
|
||||
this._processing = false;
|
||||
}
|
||||
|
||||
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
|
||||
getStatus() {
|
||||
return {
|
||||
queueSize: this._queue.length,
|
||||
inFlight: this._pending.size,
|
||||
totalSent: this._totalSent,
|
||||
totalCompleted: this._totalCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
_delay(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Clean up all pending timeouts */
|
||||
destroy() {
|
||||
for (const entry of this._pending.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error('Queue destroyed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this._queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
107
src/encryption.js
Normal file
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.
|
||||
* Privacy-first: no disk persistence, lost on restart.
|
||||
*/
|
||||
export class EventStore {
|
||||
/** @param {number} capacity - Maximum events to retain */
|
||||
constructor(capacity = 100) {
|
||||
this._capacity = capacity;
|
||||
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
||||
this._events = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new event into the ring buffer.
|
||||
* @param {string} type - Event type (e.g. "PlayerMessage")
|
||||
* @param {object} data - Event payload
|
||||
*/
|
||||
push(type, data) {
|
||||
this._events.push({
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
|
||||
// Trim to capacity
|
||||
if (this._events.length > this._capacity) {
|
||||
this._events = this._events.slice(-this._capacity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent events.
|
||||
* @param {number} count - Number of events to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getRecent(count = 20) {
|
||||
return this._events.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent events filtered by type.
|
||||
* @param {string} type - Event type to filter by
|
||||
* @param {number} count - Max number to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getByType(type, count = 20) {
|
||||
return this._events
|
||||
.filter((e) => e.type === type)
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/** @returns {number} Total events currently stored */
|
||||
get size() {
|
||||
return this._events.length;
|
||||
}
|
||||
|
||||
/** Clear all events */
|
||||
clear() {
|
||||
this._events = [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* In-memory ring buffer for Minecraft game events.
|
||||
* Privacy-first: no disk persistence by default, lost on restart.
|
||||
* Capacity configurable via EVENT_BUFFER_SIZE env var.
|
||||
*/
|
||||
export class EventStore {
|
||||
/** @param {number} capacity - Maximum events to retain */
|
||||
constructor(capacity) {
|
||||
this._capacity = capacity ?? parseInt(process.env.EVENT_BUFFER_SIZE || '1000', 10);
|
||||
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
||||
this._events = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new event into the ring buffer.
|
||||
* @param {string} type - Event type (e.g. "PlayerMessage")
|
||||
* @param {object} data - Event payload
|
||||
*/
|
||||
push(type, data) {
|
||||
this._events.push({
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
|
||||
// Trim to capacity
|
||||
if (this._events.length > this._capacity) {
|
||||
this._events = this._events.slice(-this._capacity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent events.
|
||||
* @param {number} count - Number of events to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getRecent(count = 20) {
|
||||
return this._events.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent events filtered by type.
|
||||
* @param {string} type - Event type to filter by
|
||||
* @param {number} count - Max number to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getByType(type, count = 20) {
|
||||
return this._events
|
||||
.filter((e) => e.type === type)
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent events filtered by multiple types.
|
||||
* @param {string[]} types - Event types to include
|
||||
* @param {number} count - Max number to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getByTypes(types, count = 20) {
|
||||
const typeSet = new Set(types);
|
||||
return this._events
|
||||
.filter((e) => typeSet.has(e.type))
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events since a given timestamp.
|
||||
* @param {string} timestamp - ISO timestamp
|
||||
* @param {number} count - Max number to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getSince(timestamp, count = 100) {
|
||||
return this._events
|
||||
.filter((e) => e.timestamp >= timestamp)
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/** @returns {number} Total events currently stored */
|
||||
get size() {
|
||||
return this._events.length;
|
||||
}
|
||||
|
||||
/** Clear all events */
|
||||
clear() {
|
||||
this._events = [];
|
||||
}
|
||||
}
|
||||
|
||||
396
src/grabcraft.js
Normal file
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 { startMcpServer } from './mcp-server.js';
|
||||
import { log } from './utils.js';
|
||||
|
||||
const TAG = 'Main';
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
|
||||
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
|
||||
|
||||
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
|
||||
|
||||
// Start the Bedrock WebSocket server
|
||||
const bedrock = new BedrockWebSocket({ port: WS_PORT });
|
||||
bedrock.start();
|
||||
|
||||
// Start the MCP server (Streamable HTTP transport)
|
||||
startMcpServer(bedrock, MCP_PORT);
|
||||
|
||||
log(TAG, '');
|
||||
log(TAG, '=== READY ===');
|
||||
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
|
||||
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${MCP_PORT}/mcp`);
|
||||
log(TAG, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
|
||||
log(TAG, '');
|
||||
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
|
||||
log(TAG, '');
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal) {
|
||||
log(TAG, `${signal} received, shutting down...`);
|
||||
bedrock.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
import { BedrockWebSocket } from './bedrock-ws.js';
|
||||
import { startMcpServer } from './mcp-server.js';
|
||||
import { log } from './utils.js';
|
||||
|
||||
const TAG = 'Main';
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
|
||||
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
|
||||
|
||||
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
|
||||
|
||||
// Start the Bedrock WebSocket server
|
||||
const bedrock = new BedrockWebSocket({ port: WS_PORT });
|
||||
bedrock.start();
|
||||
|
||||
// Start the MCP server (Streamable HTTP transport)
|
||||
startMcpServer(bedrock, MCP_PORT);
|
||||
|
||||
log(TAG, '');
|
||||
log(TAG, '=== READY ===');
|
||||
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
|
||||
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${MCP_PORT}/mcp`);
|
||||
log(TAG, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
|
||||
log(TAG, '');
|
||||
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
|
||||
log(TAG, '');
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal) {
|
||||
log(TAG, `${signal} received, shutting down...`);
|
||||
bedrock.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket subscribe message envelope.
|
||||
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
|
||||
* @returns {string} JSON string ready to send over WS
|
||||
*/
|
||||
export function createSubscribeMessage(eventName) {
|
||||
return JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId: randomUUID(),
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'subscribe',
|
||||
},
|
||||
body: {
|
||||
eventName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket command message envelope.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
|
||||
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||
*/
|
||||
export function createCommandMessage(commandLine) {
|
||||
// Strip leading slash if present
|
||||
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
|
||||
const requestId = randomUUID();
|
||||
|
||||
const message = JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId,
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'commandRequest',
|
||||
},
|
||||
body: {
|
||||
version: 1,
|
||||
commandLine: cmd,
|
||||
origin: {
|
||||
type: 'player',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { id: requestId, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Minecraft formatting codes (section sign + character).
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitize(text) {
|
||||
if (!text) return '';
|
||||
// Remove section sign formatting codes like §a, §l, §r etc.
|
||||
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function log(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped error log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function logError(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.error(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket subscribe message envelope.
|
||||
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
|
||||
* @returns {string} JSON string ready to send over WS
|
||||
*/
|
||||
export function createSubscribeMessage(eventName) {
|
||||
return JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId: randomUUID(),
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'subscribe',
|
||||
},
|
||||
body: {
|
||||
eventName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket command message envelope.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
|
||||
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||
*/
|
||||
export function createCommandMessage(commandLine) {
|
||||
// Strip leading slash if present
|
||||
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
|
||||
const requestId = randomUUID();
|
||||
|
||||
const message = JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId,
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'commandRequest',
|
||||
},
|
||||
body: {
|
||||
version: 1,
|
||||
commandLine: cmd,
|
||||
origin: {
|
||||
type: 'player',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { id: requestId, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket enableencryption command message.
|
||||
* @param {string} publicKeyBase64 - Server's base64-encoded public key
|
||||
* @param {string} saltBase64 - Base64-encoded 16-byte salt
|
||||
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||
*/
|
||||
export function createEnableEncryptionMessage(publicKeyBase64, saltBase64) {
|
||||
const requestId = randomUUID();
|
||||
|
||||
const message = JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId,
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'commandRequest',
|
||||
},
|
||||
body: {
|
||||
version: 1,
|
||||
commandLine: `enableencryption "${publicKeyBase64}" "${saltBase64}"`,
|
||||
origin: {
|
||||
type: 'player',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { id: requestId, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Minecraft formatting codes (section sign + character).
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitize(text) {
|
||||
if (!text) return '';
|
||||
// Remove section sign formatting codes like §a, §l, §r etc.
|
||||
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function log(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped error log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function logError(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.error(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user