/** * 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...');