- TaskQueue with 6 priority levels (IDLE → CRITICAL) - BehaviorEngine generates tasks based on persona traits: - Survival: flee (bravery-weighted), eat, seek shelter (anxiety) - Combat: attack hostiles (bravery threshold) - Social: follow players (sociability), approach for interaction - Exploration: read signs, check containers, wander (curiosity range) - Organization: inventory management (OCD quirk) - Idle: look around, unprompted chat (chatty_cathy) - Brain rewritten to use scan → generate → execute loop - New bridge actions: open_chest, close_container, transfer_item, scan_surroundings, find_blocks, attack_nearest_hostile, list_recipes, craft_item, use_block, drop_item - Traits influence: flee distance, wander range, combat willingness, social approach frequency, container curiosity - Brain passes persona traits from database to behavior engine - Unprompted AI chat via wants_ai_chat signal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
688 lines
23 KiB
JavaScript
688 lines
23 KiB
JavaScript
/**
|
|
* DougBot Bridge — mineflayer-bedrock
|
|
*
|
|
* Connects to Minecraft Bedrock server via mineflayer, exposes
|
|
* high-level actions over WebSocket to the Python controller.
|
|
*/
|
|
|
|
// Patch prismarine-physics for missing bedrock attributes
|
|
const origPhysicsPath = require.resolve('prismarine-physics');
|
|
const origPhysics = require('prismarine-physics');
|
|
const { Physics: OrigPhysics, PlayerState } = origPhysics;
|
|
|
|
function PatchedPhysics(mcData, world) {
|
|
if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) {
|
|
const attrs = [
|
|
{ name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 },
|
|
{ name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 },
|
|
{ name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 },
|
|
{ name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 },
|
|
{ name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 },
|
|
{ name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 },
|
|
{ name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 },
|
|
{ name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 },
|
|
{ name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 },
|
|
];
|
|
mcData.attributesArray = attrs;
|
|
mcData.attributes = {};
|
|
mcData.attributesByName = {};
|
|
for (const attr of attrs) {
|
|
mcData.attributes[attr.resource] = attr;
|
|
mcData.attributesByName[attr.name] = attr;
|
|
}
|
|
}
|
|
return OrigPhysics(mcData, world);
|
|
}
|
|
require.cache[origPhysicsPath] = {
|
|
id: origPhysicsPath,
|
|
exports: { Physics: PatchedPhysics, PlayerState },
|
|
loaded: true
|
|
};
|
|
|
|
const mineflayer = require('../lib/mineflayer');
|
|
const { pathfinder: pathfinderPlugin, Movements, goals } = require('mineflayer-pathfinder');
|
|
const { GoalNear, GoalFollow, GoalInvert, GoalBlock, GoalXZ } = goals;
|
|
const { WebSocketServer } = require('ws');
|
|
const { Vec3 } = require('vec3');
|
|
|
|
// --- Parse CLI args ---
|
|
const args = process.argv.slice(2);
|
|
function getArg(name, defaultVal) {
|
|
const idx = args.indexOf(`--${name}`);
|
|
if (idx === -1) return defaultVal;
|
|
if (idx + 1 < args.length && !args[idx + 1].startsWith('--')) return args[idx + 1];
|
|
return true;
|
|
}
|
|
|
|
const wsPort = parseInt(getArg('ws-port', '8765'));
|
|
const host = getArg('host', '127.0.0.1');
|
|
const port = parseInt(getArg('port', '19132'));
|
|
const username = getArg('username', 'Doug');
|
|
const connType = getArg('conn-type', 'offline');
|
|
const isOffline = args.includes('--offline') || connType === 'offline';
|
|
const realmId = getArg('realm-id', null);
|
|
const profilesFolder = getArg('profiles-folder', null);
|
|
|
|
function log(module, level, message, data) {
|
|
const entry = {
|
|
timestamp: new Date().toISOString(),
|
|
level, module, message,
|
|
...(data ? { data } : {}),
|
|
};
|
|
console.log(JSON.stringify(entry));
|
|
}
|
|
|
|
log('main', 'INFO', 'DougBot Bridge starting (mineflayer-bedrock)', {
|
|
connType, host, port, username, wsPort,
|
|
});
|
|
|
|
// --- WebSocket Server ---
|
|
const wss = new WebSocketServer({ port: wsPort });
|
|
let pythonClient = null;
|
|
|
|
wss.on('listening', () => {
|
|
log('ws_server', 'INFO', `WebSocket server listening on port ${wsPort}`);
|
|
});
|
|
|
|
function sendEvent(event, data = {}) {
|
|
if (!pythonClient) return;
|
|
try {
|
|
const msg = JSON.stringify({
|
|
type: 'event',
|
|
event,
|
|
data,
|
|
timestamp: Date.now(),
|
|
}, (key, val) => typeof val === 'bigint' ? Number(val) : val);
|
|
pythonClient.send(msg);
|
|
} catch (e) {
|
|
log('ws_server', 'ERROR', `Failed to send event: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function sendResponse(id, status, data = {}, error = null) {
|
|
if (!pythonClient) return;
|
|
try {
|
|
const msg = JSON.stringify({
|
|
id, type: 'response', status,
|
|
...(status === 'success' ? { data } : { error }),
|
|
}, (key, val) => typeof val === 'bigint' ? Number(val) : val);
|
|
pythonClient.send(msg);
|
|
} catch (e) {
|
|
log('ws_server', 'ERROR', `Failed to send response: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// --- Create mineflayer bot ---
|
|
const botOptions = {
|
|
host,
|
|
port,
|
|
username,
|
|
version: 'bedrock_1.21.130',
|
|
bedrockProtocolVersion: '26.10',
|
|
auth: isOffline ? 'offline' : 'microsoft',
|
|
offline: isOffline,
|
|
raknetBackend: 'jsp-raknet',
|
|
respawn: true,
|
|
};
|
|
|
|
if (realmId) {
|
|
botOptions.realms = { realmId };
|
|
}
|
|
if (profilesFolder) {
|
|
botOptions.profilesFolder = profilesFolder;
|
|
}
|
|
|
|
log('client', 'INFO', `Connecting to ${host}:${port} as ${username}`);
|
|
|
|
const bot = mineflayer.createBot(botOptions);
|
|
bot.loadPlugin(pathfinderPlugin);
|
|
|
|
let spawned = false;
|
|
let movements = null;
|
|
|
|
// --- Bot Events → WebSocket Events ---
|
|
bot.once('spawn', () => {
|
|
spawned = true;
|
|
movements = new Movements(bot);
|
|
movements.canDig = false; // Don't dig for pathfinding
|
|
movements.canOpenDoors = false; // Don't open doors
|
|
movements.allowSprinting = true;
|
|
movements.allowParkour = true;
|
|
bot.pathfinder.setMovements(movements);
|
|
|
|
const pos = bot.entity.position;
|
|
log('client', 'INFO', `Spawned at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
|
|
sendEvent('spawn_complete', {
|
|
position: { x: pos.x, y: pos.y, z: pos.z },
|
|
});
|
|
});
|
|
|
|
// Bedrock chat: listen on the raw text packet for reliable message capture
|
|
// The 'chat' event depends on regex matching which may not work for Bedrock format
|
|
bot._client.on('text', (packet) => {
|
|
if (packet.type !== 'chat') return;
|
|
const sender = packet.source_name || '';
|
|
const message = packet.message || '';
|
|
if (!sender || sender === username) return;
|
|
log('client', 'INFO', `Chat: <${sender}> ${message}`);
|
|
sendEvent('chat_message', { sender, message });
|
|
});
|
|
|
|
// Also keep the mineflayer chat event as backup
|
|
bot.on('chat', (sender, message) => {
|
|
// Already handled by text packet listener above in most cases
|
|
// This catches any messages that come through the pattern system
|
|
});
|
|
|
|
bot.on('health', () => {
|
|
sendEvent('health_changed', {
|
|
health: bot.health,
|
|
food: bot.food,
|
|
});
|
|
});
|
|
|
|
bot.on('death', () => {
|
|
const pos = bot.entity.position;
|
|
log('client', 'WARN', 'Doug died!');
|
|
sendEvent('death', {
|
|
message: 'Doug died',
|
|
position: { x: pos.x, y: pos.y, z: pos.z },
|
|
});
|
|
});
|
|
|
|
bot.on('respawn', () => {
|
|
log('client', 'INFO', 'Respawned');
|
|
sendEvent('respawn', {});
|
|
});
|
|
|
|
bot.on('playerJoined', (player) => {
|
|
if (player.username === username) return;
|
|
log('client', 'INFO', `Player joined: ${player.username}`);
|
|
sendEvent('player_joined', { username: player.username });
|
|
});
|
|
|
|
bot.on('playerLeft', (player) => {
|
|
if (player.username === username) return;
|
|
log('client', 'INFO', `Player left: ${player.username}`);
|
|
sendEvent('player_left', { username: player.username });
|
|
});
|
|
|
|
bot.on('time', () => {
|
|
sendEvent('time_update', {
|
|
gameTime: bot.time.age,
|
|
dayTime: bot.time.timeOfDay,
|
|
});
|
|
});
|
|
|
|
bot.on('entitySpawn', (entity) => {
|
|
sendEvent('entity_spawned', {
|
|
entityId: entity.id,
|
|
type: entity.name || entity.type,
|
|
name: entity.username || entity.nametag || entity.name,
|
|
position: entity.position ? { x: entity.position.x, y: entity.position.y, z: entity.position.z } : null,
|
|
});
|
|
});
|
|
|
|
bot.on('entityGone', (entity) => {
|
|
sendEvent('entity_removed', { entityId: entity.id });
|
|
});
|
|
|
|
bot.on('kicked', (reason) => {
|
|
log('client', 'ERROR', `Kicked: ${reason}`);
|
|
sendEvent('error', { message: `Kicked: ${reason}`, code: 'kicked' });
|
|
});
|
|
|
|
bot.on('error', (err) => {
|
|
log('client', 'ERROR', `Bot error: ${err.message}`);
|
|
sendEvent('error', { message: err.message });
|
|
});
|
|
|
|
bot.on('end', (reason) => {
|
|
log('client', 'INFO', `Disconnected: ${reason}`);
|
|
sendEvent('disconnected', { reason: reason || 'unknown' });
|
|
});
|
|
|
|
// Pathfinder events
|
|
bot.on('goal_reached', () => {
|
|
sendEvent('movement_complete', {});
|
|
});
|
|
|
|
bot.on('path_update', (results) => {
|
|
if (results.status === 'noPath') {
|
|
sendEvent('movement_failed', { reason: 'no_path' });
|
|
}
|
|
});
|
|
|
|
// --- Hostile mob list ---
|
|
const HOSTILE_MOBS = new Set([
|
|
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider',
|
|
'minecraft:enderman', 'minecraft:witch', 'minecraft:slime', 'minecraft:phantom',
|
|
'minecraft:drowned', 'minecraft:husk', 'minecraft:stray', 'minecraft:blaze',
|
|
'minecraft:ghast', 'minecraft:magma_cube', 'minecraft:pillager', 'minecraft:vindicator',
|
|
'minecraft:evoker', 'minecraft:ravager', 'minecraft:vex', 'minecraft:warden',
|
|
'minecraft:wither_skeleton', 'minecraft:cave_spider', 'minecraft:silverfish',
|
|
'zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch', 'slime',
|
|
'phantom', 'drowned', 'husk', 'stray', 'blaze', 'ghast', 'magma_cube',
|
|
'pillager', 'vindicator', 'evoker', 'ravager', 'vex', 'warden',
|
|
'wither_skeleton', 'cave_spider', 'silverfish',
|
|
]);
|
|
|
|
function isHostile(entity) {
|
|
const name = entity.name || entity.type || '';
|
|
return HOSTILE_MOBS.has(name) || HOSTILE_MOBS.has(`minecraft:${name}`);
|
|
}
|
|
|
|
// --- WebSocket Action Handlers ---
|
|
async function handleAction(action, params = {}) {
|
|
if (!spawned && action !== 'status') {
|
|
throw new Error('Bot not spawned yet');
|
|
}
|
|
|
|
switch (action) {
|
|
case 'status': {
|
|
const pos = spawned ? bot.entity.position : { x: 0, y: 0, z: 0 };
|
|
return {
|
|
connected: true,
|
|
spawned,
|
|
position: { x: pos.x, y: pos.y, z: pos.z },
|
|
health: bot.health || 0,
|
|
food: bot.food || 0,
|
|
dayTime: bot.time?.timeOfDay || 0,
|
|
gameTime: bot.time?.age || 0,
|
|
};
|
|
}
|
|
|
|
case 'move_to': {
|
|
const { x, y, z, range } = params;
|
|
bot.pathfinder.setGoal(new GoalNear(x, y, z, range || 1));
|
|
return { moving: true, target: { x, y, z } };
|
|
}
|
|
|
|
case 'move_relative': {
|
|
const { dx, dy, dz } = params;
|
|
const pos = bot.entity.position;
|
|
const target = new Vec3(pos.x + (dx || 0), pos.y + (dy || 0), pos.z + (dz || 0));
|
|
bot.pathfinder.setGoal(new GoalNear(target.x, target.y, target.z, 1));
|
|
return { moving: true, target: { x: target.x, y: target.y, z: target.z } };
|
|
}
|
|
|
|
case 'follow_player': {
|
|
const { name, range } = params;
|
|
const player = bot.players[name];
|
|
if (!player || !player.entity) {
|
|
throw new Error(`Player ${name} not found or not visible`);
|
|
}
|
|
bot.pathfinder.setGoal(new GoalFollow(player.entity, range || 3), true);
|
|
return { following: name };
|
|
}
|
|
|
|
case 'look_at': {
|
|
const { x, y, z } = params;
|
|
await bot.lookAt(new Vec3(x, y, z));
|
|
return { looked_at: { x, y, z } };
|
|
}
|
|
|
|
case 'send_chat': {
|
|
const { message } = params;
|
|
bot.chat(message);
|
|
log('client', 'INFO', `Sent chat: ${message}`);
|
|
return { sent: true };
|
|
}
|
|
|
|
case 'attack_entity': {
|
|
const { id } = params;
|
|
const entity = bot.entities[id];
|
|
if (!entity) throw new Error(`Entity ${id} not found`);
|
|
bot.attack(entity);
|
|
return { attacked: id };
|
|
}
|
|
|
|
case 'dig_block': {
|
|
const { x, y, z } = params;
|
|
const block = bot.blockAt(new Vec3(x, y, z));
|
|
if (!block) throw new Error(`No block at ${x},${y},${z}`);
|
|
await bot.dig(block);
|
|
return { dug: { x, y, z } };
|
|
}
|
|
|
|
case 'place_block': {
|
|
const { x, y, z, face } = params;
|
|
const refBlock = bot.blockAt(new Vec3(x, y, z));
|
|
if (!refBlock) throw new Error(`No block at ${x},${y},${z}`);
|
|
const faceVec = face ? new Vec3(face.x, face.y, face.z) : new Vec3(0, 1, 0);
|
|
await bot.placeBlock(refBlock, faceVec);
|
|
return { placed: { x, y, z } };
|
|
}
|
|
|
|
case 'equip_item': {
|
|
const { name, destination } = params;
|
|
const item = bot.inventory.items().find(i => i.name === name);
|
|
if (!item) throw new Error(`Item ${name} not in inventory`);
|
|
await bot.equip(item, destination || 'hand');
|
|
return { equipped: name };
|
|
}
|
|
|
|
case 'stop': {
|
|
bot.pathfinder.stop();
|
|
bot.clearControlStates();
|
|
return { stopped: true };
|
|
}
|
|
|
|
case 'get_nearby_entities': {
|
|
const radius = params.radius || 16;
|
|
const pos = bot.entity.position;
|
|
const entities = [];
|
|
for (const entity of Object.values(bot.entities)) {
|
|
if (entity === bot.entity) continue;
|
|
if (!entity.position) continue;
|
|
const dist = entity.position.distanceTo(pos);
|
|
if (dist <= radius) {
|
|
entities.push({
|
|
id: entity.id,
|
|
type: entity.name || entity.type || 'unknown',
|
|
name: entity.username || entity.nametag || entity.name || 'unknown',
|
|
position: { x: entity.position.x, y: entity.position.y, z: entity.position.z },
|
|
distance: dist,
|
|
isHostile: isHostile(entity),
|
|
isPlayer: entity.type === 'player',
|
|
});
|
|
}
|
|
}
|
|
return { entities };
|
|
}
|
|
|
|
case 'get_inventory': {
|
|
const items = bot.inventory.items().map(item => ({
|
|
name: item.name,
|
|
count: item.count,
|
|
slot: item.slot,
|
|
displayName: item.displayName,
|
|
}));
|
|
return { items };
|
|
}
|
|
|
|
case 'get_block_at': {
|
|
const { x, y, z } = params;
|
|
const block = bot.blockAt(new Vec3(x, y, z));
|
|
if (!block) return { block: null };
|
|
return {
|
|
block: {
|
|
name: block.name,
|
|
type: block.type,
|
|
position: { x: block.position.x, y: block.position.y, z: block.position.z },
|
|
hardness: block.hardness,
|
|
diggable: block.diggable,
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Chest / Container Interaction ---
|
|
case 'open_chest': {
|
|
const { x, y, z } = params;
|
|
const chestBlock = bot.blockAt(new Vec3(x, y, z));
|
|
if (!chestBlock) throw new Error(`No block at ${x},${y},${z}`);
|
|
const chest = await bot.openContainer(chestBlock);
|
|
const items = chest.containerItems().map(item => ({
|
|
name: item.name,
|
|
count: item.count,
|
|
slot: item.slot,
|
|
displayName: item.displayName,
|
|
}));
|
|
// Store reference for subsequent operations
|
|
bot._openContainer = chest;
|
|
return { items, slots: chest.containerItems().length };
|
|
}
|
|
|
|
case 'close_container': {
|
|
if (bot._openContainer) {
|
|
bot._openContainer.close();
|
|
bot._openContainer = null;
|
|
}
|
|
return { closed: true };
|
|
}
|
|
|
|
case 'transfer_item': {
|
|
// Move items between containers/inventory
|
|
const { itemName, count, toContainer } = params;
|
|
if (!bot._openContainer) throw new Error('No container open');
|
|
const container = bot._openContainer;
|
|
if (toContainer) {
|
|
// From inventory to container: deposit
|
|
const item = bot.inventory.items().find(i => i.name === itemName);
|
|
if (!item) throw new Error(`Item ${itemName} not in inventory`);
|
|
await container.deposit(item.type, item.metadata, count || item.count);
|
|
} else {
|
|
// From container to inventory: withdraw
|
|
const item = container.containerItems().find(i => i.name === itemName);
|
|
if (!item) throw new Error(`Item ${itemName} not in container`);
|
|
await container.withdraw(item.type, item.metadata, count || item.count);
|
|
}
|
|
return { transferred: itemName, count: count || 1 };
|
|
}
|
|
|
|
// --- Surroundings Scan ---
|
|
case 'scan_surroundings': {
|
|
const radius = params.radius || 8;
|
|
const pos = bot.entity.position;
|
|
const result = {
|
|
position: { x: pos.x, y: pos.y, z: pos.z },
|
|
blocks: {}, // Notable blocks nearby
|
|
entities: [], // Nearby entities
|
|
players: [], // Nearby players
|
|
signs: [], // Signs with text
|
|
containers: [],// Chests, barrels, etc.
|
|
time: bot.time?.timeOfDay || 0,
|
|
health: bot.health,
|
|
food: bot.food,
|
|
isRaining: bot.isRaining || false,
|
|
};
|
|
|
|
// Scan blocks in radius
|
|
const containerTypes = new Set(['chest', 'trapped_chest', 'barrel', 'shulker_box', 'ender_chest']);
|
|
const signTypes = new Set(['oak_sign', 'spruce_sign', 'birch_sign', 'jungle_sign', 'acacia_sign',
|
|
'dark_oak_sign', 'mangrove_sign', 'cherry_sign', 'bamboo_sign', 'crimson_sign', 'warped_sign',
|
|
'oak_wall_sign', 'spruce_wall_sign', 'birch_wall_sign', 'jungle_wall_sign', 'acacia_wall_sign',
|
|
'dark_oak_wall_sign', 'mangrove_wall_sign', 'cherry_wall_sign', 'bamboo_wall_sign',
|
|
'crimson_wall_sign', 'warped_wall_sign', 'standing_sign', 'wall_sign']);
|
|
const interestingBlocks = new Set(['crafting_table', 'furnace', 'blast_furnace', 'smoker',
|
|
'anvil', 'enchanting_table', 'brewing_stand', 'bed', 'door', 'campfire', 'soul_campfire',
|
|
'torch', 'lantern']);
|
|
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
for (let dy = -4; dy <= 4; dy++) {
|
|
for (let dz = -radius; dz <= radius; dz++) {
|
|
if (dx * dx + dz * dz > radius * radius) continue;
|
|
const blockPos = pos.offset(dx, dy, dz);
|
|
const block = bot.blockAt(blockPos);
|
|
if (!block || block.name === 'air') continue;
|
|
|
|
const bName = block.name.replace('minecraft:', '');
|
|
|
|
if (containerTypes.has(bName)) {
|
|
result.containers.push({
|
|
type: bName,
|
|
position: { x: blockPos.x, y: blockPos.y, z: blockPos.z },
|
|
});
|
|
}
|
|
if (signTypes.has(bName)) {
|
|
// Try to read sign text
|
|
let text = '';
|
|
try {
|
|
const signEntity = block.blockEntity || block.entity;
|
|
if (signEntity && signEntity.Text) text = signEntity.Text;
|
|
else if (block.signText) text = block.signText;
|
|
} catch (e) {}
|
|
result.signs.push({
|
|
position: { x: blockPos.x, y: blockPos.y, z: blockPos.z },
|
|
text: text || '(unreadable)',
|
|
});
|
|
}
|
|
if (interestingBlocks.has(bName)) {
|
|
if (!result.blocks[bName]) result.blocks[bName] = [];
|
|
result.blocks[bName].push({ x: blockPos.x, y: blockPos.y, z: blockPos.z });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Entities and players
|
|
for (const entity of Object.values(bot.entities)) {
|
|
if (entity === bot.entity) continue;
|
|
if (!entity.position) continue;
|
|
const dist = entity.position.distanceTo(pos);
|
|
if (dist > radius) continue;
|
|
const info = {
|
|
id: entity.id,
|
|
type: entity.name || entity.type || 'unknown',
|
|
name: entity.username || entity.nametag || entity.name || 'unknown',
|
|
position: { x: entity.position.x, y: entity.position.y, z: entity.position.z },
|
|
distance: dist,
|
|
isHostile: isHostile(entity),
|
|
};
|
|
if (entity.type === 'player') {
|
|
result.players.push(info);
|
|
} else {
|
|
result.entities.push(info);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// --- Find Blocks ---
|
|
case 'find_blocks': {
|
|
const { blockName, radius: searchRadius, count: maxCount } = params;
|
|
const r = searchRadius || 32;
|
|
const max = maxCount || 10;
|
|
const blocks = bot.findBlocks({
|
|
matching: (block) => {
|
|
const name = block.name.replace('minecraft:', '');
|
|
return name === blockName || name.includes(blockName);
|
|
},
|
|
maxDistance: r,
|
|
count: max,
|
|
});
|
|
return {
|
|
blocks: blocks.map(pos => ({
|
|
position: { x: pos.x, y: pos.y, z: pos.z },
|
|
name: bot.blockAt(pos)?.name || blockName,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// --- Combat ---
|
|
case 'attack_nearest_hostile': {
|
|
const range = params.range || 5;
|
|
const hostiles = [];
|
|
for (const entity of Object.values(bot.entities)) {
|
|
if (entity === bot.entity || !entity.position) continue;
|
|
if (!isHostile(entity)) continue;
|
|
const dist = entity.position.distanceTo(bot.entity.position);
|
|
if (dist <= range) hostiles.push({ entity, dist });
|
|
}
|
|
if (hostiles.length === 0) return { attacked: false, reason: 'no_hostiles' };
|
|
hostiles.sort((a, b) => a.dist - b.dist);
|
|
const target = hostiles[0].entity;
|
|
bot.attack(target);
|
|
return { attacked: true, target: target.name || target.type, distance: hostiles[0].dist };
|
|
}
|
|
|
|
// --- Crafting ---
|
|
case 'list_recipes': {
|
|
const { itemName } = params;
|
|
const mcData = require('minecraft-data')(bot.version);
|
|
const item = mcData.itemsByName[itemName];
|
|
if (!item) return { recipes: [], error: `Unknown item: ${itemName}` };
|
|
const recipes = bot.recipesFor(item.id);
|
|
return {
|
|
recipes: recipes.map((r, i) => ({
|
|
index: i,
|
|
ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
case 'craft_item': {
|
|
const { itemName, count: craftCount } = params;
|
|
const mcData = require('minecraft-data')(bot.version);
|
|
const item = mcData.itemsByName[itemName];
|
|
if (!item) throw new Error(`Unknown item: ${itemName}`);
|
|
|
|
// Find crafting table nearby if needed
|
|
const craftingTable = bot.findBlock({
|
|
matching: (block) => block.name.includes('crafting_table'),
|
|
maxDistance: 4,
|
|
});
|
|
const recipes = bot.recipesFor(item.id, null, null, craftingTable || undefined);
|
|
if (recipes.length === 0) throw new Error(`No recipe found for ${itemName}`);
|
|
await bot.craft(recipes[0], craftCount || 1, craftingTable || undefined);
|
|
return { crafted: itemName, count: craftCount || 1 };
|
|
}
|
|
|
|
// --- Use/Activate Block ---
|
|
case 'use_block': {
|
|
const { x, y, z } = params;
|
|
const block = bot.blockAt(new Vec3(x, y, z));
|
|
if (!block) throw new Error(`No block at ${x},${y},${z}`);
|
|
await bot.activateBlock(block);
|
|
return { used: block.name };
|
|
}
|
|
|
|
// --- Drop/Toss Items ---
|
|
case 'drop_item': {
|
|
const { itemName, count: dropCount } = params;
|
|
const item = bot.inventory.items().find(i => i.name === itemName);
|
|
if (!item) throw new Error(`Item ${itemName} not in inventory`);
|
|
await bot.toss(item.type, item.metadata, dropCount || 1);
|
|
return { dropped: itemName, count: dropCount || 1 };
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown action: ${action}`);
|
|
}
|
|
}
|
|
|
|
// --- WebSocket Connection Handler ---
|
|
wss.on('connection', (ws) => {
|
|
log('ws_server', 'INFO', 'Python controller connected');
|
|
pythonClient = ws;
|
|
|
|
ws.on('message', async (data) => {
|
|
try {
|
|
const msg = JSON.parse(data.toString());
|
|
if (msg.type !== 'request') return;
|
|
|
|
const { id, action, params } = msg;
|
|
try {
|
|
const result = await handleAction(action, params || {});
|
|
sendResponse(id, 'success', result);
|
|
} catch (e) {
|
|
sendResponse(id, 'error', null, e.message);
|
|
}
|
|
} catch (e) {
|
|
log('ws_server', 'ERROR', `Bad message: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
log('ws_server', 'INFO', 'Python controller disconnected');
|
|
pythonClient = null;
|
|
});
|
|
});
|
|
|
|
// --- Graceful shutdown ---
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
|
|
function shutdown() {
|
|
log('main', 'INFO', 'Shutting down...');
|
|
try {
|
|
bot.pathfinder.stop();
|
|
bot.clearControlStates();
|
|
} catch (e) {}
|
|
try { bot._client.close(); } catch (e) {}
|
|
try { wss.close(); } catch (e) {}
|
|
setTimeout(() => process.exit(0), 500);
|
|
}
|
|
|
|
log('main', 'INFO', 'Bridge is running. Waiting for Python controller...');
|