dougbot/bridge/src/index.js
roberts b609d4c896 Phase 3: Task queue, behavior engine, trait-driven decisions
- 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>
2026-03-30 12:48:15 -05:00

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