Rebuild bridge on mineflayer-bedrock — real movement!
- Replace raw bedrock-protocol packets with mineflayer-bedrock - Movement uses pathfinder (setGoal/GoalNear) — works on server-auth BDS - No cheats, no OP, no teleports — Doug walks like a real player - Bridge is now plain JS (no TypeScript) with Node 22 - Brain uses move_to with pathfinder instead of fake teleport steps - Fix MariaDB connection timeout with auto-reconnect - Tested: bot spawns and walks on vanilla BDS 1.26.11 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9aa0abbf59
commit
d0a96ce028
6 changed files with 9483 additions and 153 deletions
8941
bridge/package-lock.json
generated
8941
bridge/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,25 @@
|
|||
{
|
||||
"name": "dougbot-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Minecraft Bedrock protocol bridge for DougBot",
|
||||
"main": "dist/index.js",
|
||||
"version": "0.2.0",
|
||||
"description": "Minecraft Bedrock bridge for DougBot (mineflayer-bedrock)",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"postinstall": "node patches/patch-raknet.js",
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node --experimental-strip-types --disable-warning=ExperimentalWarning src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"mineflayer": "file:./lib/mineflayer",
|
||||
"prismarine-registry": "file:./lib/prismarine-registry",
|
||||
"prismarine-chunk": "file:./lib/prismarine-chunk",
|
||||
"mineflayer-pathfinder": "^2.4.5",
|
||||
"bedrock-protocol": "^3.55.0",
|
||||
"minecraft-data": "^3.108.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0"
|
||||
"prismarine-physics": "^1.9.0",
|
||||
"ws": "^8.18.0",
|
||||
"vec3": "^0.1.10",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.7.0"
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
456
bridge/src/index.js
Normal file
456
bridge/src/index.js
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* 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 },
|
||||
});
|
||||
});
|
||||
|
||||
bot.on('chat', (sender, message) => {
|
||||
if (sender === username) return; // Ignore own messages
|
||||
log('client', 'INFO', `Chat: <${sender}> ${message}`);
|
||||
sendEvent('chat_message', { sender, message });
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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...');
|
||||
|
|
@ -47,14 +47,14 @@ class NodeManager(QObject):
|
|||
"is in the project root with package.json."
|
||||
)
|
||||
|
||||
def _ensure_built(self) -> Path:
|
||||
"""Ensure the bridge TypeScript is compiled."""
|
||||
def _ensure_ready(self) -> Path:
|
||||
"""Ensure the bridge is ready to run (dependencies installed)."""
|
||||
bridge_dir = self._find_bridge_dir()
|
||||
dist_dir = bridge_dir / "dist"
|
||||
entry_file = dist_dir / "index.js"
|
||||
entry_file = bridge_dir / "src" / "index.js"
|
||||
|
||||
if not entry_file.exists():
|
||||
log.info("Bridge not built, compiling TypeScript...")
|
||||
raise FileNotFoundError(f"Bridge entry point not found: {entry_file}")
|
||||
|
||||
# Check if node_modules exists
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
log.info("Installing bridge dependencies...")
|
||||
|
|
@ -67,18 +67,6 @@ class NodeManager(QObject):
|
|||
stderr = proc.readAllStandardError().data().decode()
|
||||
raise RuntimeError(f"npm install failed: {stderr}")
|
||||
|
||||
# Compile TypeScript
|
||||
proc = QProcess()
|
||||
proc.setWorkingDirectory(str(bridge_dir))
|
||||
proc.start("npx", ["tsc"])
|
||||
proc.waitForFinished(60000) # 1 minute
|
||||
|
||||
if proc.exitCode() != 0:
|
||||
stderr = proc.readAllStandardError().data().decode()
|
||||
raise RuntimeError(f"TypeScript compilation failed: {stderr}")
|
||||
|
||||
log.info("Bridge compiled successfully")
|
||||
|
||||
self._bridge_dir = bridge_dir
|
||||
return entry_file
|
||||
|
||||
|
|
@ -111,26 +99,34 @@ class NodeManager(QObject):
|
|||
self._ws_port = ws_port
|
||||
|
||||
try:
|
||||
entry_file = self._ensure_built()
|
||||
entry_file = self._ensure_ready()
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
return
|
||||
|
||||
# Find node executable - prefer fnm-managed Node 20 (raknet-native crashes on Node 25)
|
||||
# Find node executable - need Node 22+ for mineflayer-bedrock (.mts files)
|
||||
node_path = None
|
||||
fnm_node = Path.home() / ".local" / "share" / "fnm" / "node-versions" / "v20.20.2" / "installation" / "bin" / "node"
|
||||
if fnm_node.exists():
|
||||
node_path = str(fnm_node)
|
||||
log.info(f"Using fnm Node 20: {node_path}")
|
||||
else:
|
||||
# Check fnm-managed Node 22 first
|
||||
fnm_base = Path.home() / ".local" / "share" / "fnm" / "node-versions"
|
||||
if fnm_base.exists():
|
||||
for version_dir in sorted(fnm_base.iterdir(), reverse=True):
|
||||
if version_dir.name.startswith("v22"):
|
||||
candidate = version_dir / "installation" / "bin" / "node"
|
||||
if candidate.exists():
|
||||
node_path = str(candidate)
|
||||
log.info(f"Using fnm Node 22: {node_path}")
|
||||
break
|
||||
if not node_path:
|
||||
node_path = shutil.which("node")
|
||||
|
||||
if not node_path:
|
||||
self.error_occurred.emit("Node.js not found. Please install Node.js.")
|
||||
return
|
||||
|
||||
# Build arguments based on connection type
|
||||
# Build arguments — need --experimental-strip-types for .mts files
|
||||
args = [
|
||||
"--experimental-strip-types",
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
str(entry_file),
|
||||
"--ws-port", str(ws_port),
|
||||
"--conn-type", conn_type,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""
|
||||
Doug's Brain — the autonomous decision loop.
|
||||
Runs every 2 seconds and decides what Doug should do next.
|
||||
Uses mineflayer pathfinder for real movement.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
|
@ -58,6 +59,8 @@ class DougBrain(QObject):
|
|||
"""Stop the brain loop."""
|
||||
self._running = False
|
||||
self._tick_timer.stop()
|
||||
# Tell bridge to stop moving
|
||||
self._ws.send_request("stop", {})
|
||||
log.info("Brain stopped")
|
||||
|
||||
def update_from_event(self, event: str, data: dict):
|
||||
|
|
@ -79,6 +82,11 @@ class DougBrain(QObject):
|
|||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
elif event == "movement_failed":
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
log.debug("Movement failed (no path)")
|
||||
|
||||
def _tick(self):
|
||||
"""One brain tick — observe, decide, act."""
|
||||
|
|
@ -88,12 +96,13 @@ class DougBrain(QObject):
|
|||
|
||||
self._ticks_since_chat += 1
|
||||
|
||||
# If movement is taking too long (> 15 sec), cancel it
|
||||
if self._is_moving and (time.time() - self._action_start_time > 15):
|
||||
# If movement is taking too long (> 30 sec), cancel it
|
||||
if self._is_moving and (time.time() - self._action_start_time > 30):
|
||||
self._ws.send_request("stop", {})
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
log.debug("Movement timed out, going idle")
|
||||
log.debug("Movement timed out, stopping")
|
||||
|
||||
# Request current status from bridge
|
||||
if not self._pending_status:
|
||||
|
|
@ -124,7 +133,7 @@ class DougBrain(QObject):
|
|||
entities = response.data.get("entities", [])
|
||||
self._nearby_players = [
|
||||
e for e in entities
|
||||
if e.get("type") == "player" and e.get("name") != self._doug_name
|
||||
if e.get("isPlayer") and e.get("name") != self._doug_name
|
||||
]
|
||||
self._nearby_hostiles = [
|
||||
e for e in entities if e.get("isHostile", False)
|
||||
|
|
@ -133,41 +142,14 @@ class DougBrain(QObject):
|
|||
def _decide(self):
|
||||
"""Core decision logic — what should Doug do right now?"""
|
||||
|
||||
# Continue walking if we have steps left
|
||||
if self._is_moving and self._current_action == "wandering":
|
||||
if hasattr(self, '_walk_steps_left') and self._walk_steps_left > 0:
|
||||
self._walk_steps_left -= 1
|
||||
target = getattr(self, '_walk_target', None)
|
||||
if target:
|
||||
dx = target["x"] - self._position["x"]
|
||||
dz = target["z"] - self._position["z"]
|
||||
dist = math.sqrt(dx * dx + dz * dz)
|
||||
if dist > 0.5:
|
||||
nx = dx / dist
|
||||
nz = dz / dist
|
||||
step = min(1.5, dist)
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": nx * step,
|
||||
"dy": 0,
|
||||
"dz": nz * step,
|
||||
})
|
||||
self._position["x"] += nx * step
|
||||
self._position["z"] += nz * step
|
||||
return
|
||||
# Arrived
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
return
|
||||
|
||||
# Don't interrupt other actions
|
||||
# Don't interrupt current actions (pathfinder is handling it)
|
||||
if self._is_moving:
|
||||
return
|
||||
|
||||
idle_duration = time.time() - self._idle_since
|
||||
|
||||
# Priority 1: Flee from CLOSE hostiles (within 8 blocks) when hurt
|
||||
close_hostiles = [h for h in self._nearby_hostiles if self._distance_to(h) < 8]
|
||||
close_hostiles = [h for h in self._nearby_hostiles if h.get("distance", 99) < 8]
|
||||
if close_hostiles and self._health < 14:
|
||||
self._flee_from_hostile(close_hostiles[0])
|
||||
return
|
||||
|
|
@ -178,22 +160,19 @@ class DougBrain(QObject):
|
|||
return
|
||||
|
||||
# Priority 3: Look around when idle
|
||||
if idle_duration > 2 and random.random() < 0.4:
|
||||
if idle_duration > 2 and random.random() < 0.3:
|
||||
self._look_around()
|
||||
return
|
||||
|
||||
def _distance_to(self, entity: dict) -> float:
|
||||
"""Distance from Doug to an entity."""
|
||||
epos = entity.get("position", {})
|
||||
dx = self._position["x"] - epos.get("x", 0)
|
||||
dz = self._position["z"] - epos.get("z", 0)
|
||||
return math.sqrt(dx * dx + dz * dz)
|
||||
return entity.get("distance", 99)
|
||||
|
||||
def _wander(self):
|
||||
"""Walk to a random nearby position using small teleport steps."""
|
||||
# Pick a random direction and distance (3-8 blocks)
|
||||
"""Walk to a random nearby position using pathfinder."""
|
||||
# Pick a random direction and distance (5-15 blocks)
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
dist = random.uniform(3, 8)
|
||||
dist = random.uniform(5, 15)
|
||||
target_x = self._position["x"] + math.cos(angle) * dist
|
||||
target_z = self._position["z"] + math.sin(angle) * dist
|
||||
|
||||
|
|
@ -202,44 +181,26 @@ class DougBrain(QObject):
|
|||
dx = target_x - self._spawn_pos["x"]
|
||||
dz = target_z - self._spawn_pos["z"]
|
||||
if math.sqrt(dx * dx + dz * dz) > 50:
|
||||
# Walk back toward spawn
|
||||
angle = math.atan2(
|
||||
self._spawn_pos["z"] - self._position["z"],
|
||||
self._spawn_pos["x"] - self._position["x"],
|
||||
)
|
||||
target_x = self._position["x"] + math.cos(angle) * 6
|
||||
target_z = self._position["z"] + math.sin(angle) * 6
|
||||
target_x = self._position["x"] + math.cos(angle) * 8
|
||||
target_z = self._position["z"] + math.sin(angle) * 8
|
||||
log.debug("Wandering back toward spawn")
|
||||
|
||||
# Use small relative teleports (~1 block per step) to simulate walking
|
||||
dx = target_x - self._position["x"]
|
||||
dz = target_z - self._position["z"]
|
||||
total_dist = math.sqrt(dx * dx + dz * dz)
|
||||
if total_dist < 0.5:
|
||||
return
|
||||
|
||||
# Normalize direction
|
||||
nx = dx / total_dist
|
||||
nz = dz / total_dist
|
||||
|
||||
# Take a single step of ~1.5 blocks toward target
|
||||
step = min(1.5, total_dist)
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": nx * step,
|
||||
"dy": 0,
|
||||
"dz": nz * step,
|
||||
# Use pathfinder to walk there
|
||||
self._ws.send_request("move_to", {
|
||||
"x": target_x,
|
||||
"y": self._position["y"],
|
||||
"z": target_z,
|
||||
"range": 2,
|
||||
})
|
||||
|
||||
# Update local position estimate
|
||||
self._position["x"] += nx * step
|
||||
self._position["z"] += nz * step
|
||||
|
||||
# Keep walking for a few ticks (set timer to continue)
|
||||
self._walk_target = {"x": target_x, "z": target_z}
|
||||
self._walk_steps_left = int(total_dist / 1.5)
|
||||
self._is_moving = True
|
||||
self._action_start_time = time.time()
|
||||
self._current_action = "wandering"
|
||||
log.debug(f"Walking toward ({target_x:.0f}, {self._position['y']:.0f}, {target_z:.0f}) — {total_dist:.0f} blocks")
|
||||
|
||||
def _look_around(self):
|
||||
"""Look at a random direction."""
|
||||
|
|
@ -254,24 +215,24 @@ class DougBrain(QObject):
|
|||
})
|
||||
|
||||
def _flee_from_hostile(self, hostile: dict):
|
||||
"""Run away from a hostile mob using /tp."""
|
||||
"""Run away from a hostile mob using pathfinder."""
|
||||
hpos = hostile.get("position", {})
|
||||
dx = self._position["x"] - hpos.get("x", 0)
|
||||
dz = self._position["z"] - hpos.get("z", 0)
|
||||
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
||||
|
||||
# Sprint 3 blocks away in opposite direction
|
||||
flee_dx = (dx / dist) * 3
|
||||
flee_dz = (dz / dist) * 3
|
||||
# Pathfind 10 blocks away from mob
|
||||
flee_dist = 10
|
||||
flee_x = self._position["x"] + (dx / dist) * flee_dist
|
||||
flee_z = self._position["z"] + (dz / dist) * flee_dist
|
||||
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": flee_dx,
|
||||
"dy": 0,
|
||||
"dz": flee_dz,
|
||||
self._ws.send_request("move_to", {
|
||||
"x": flee_x,
|
||||
"y": self._position["y"],
|
||||
"z": flee_z,
|
||||
"range": 2,
|
||||
})
|
||||
|
||||
self._position["x"] += flee_dx
|
||||
self._position["z"] += flee_dz
|
||||
self._is_moving = True
|
||||
self._action_start_time = time.time()
|
||||
self._current_action = "fleeing"
|
||||
|
|
|
|||
|
|
@ -103,19 +103,16 @@ class MariaDBConnection(DatabaseConnection):
|
|||
**kwargs):
|
||||
super().__init__()
|
||||
self._db_type = "mariadb"
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._user = user
|
||||
self._password = password
|
||||
self._database = database
|
||||
|
||||
try:
|
||||
import mysql.connector
|
||||
self._conn = mysql.connector.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
autocommit=True,
|
||||
)
|
||||
self._mysql = mysql.connector
|
||||
log.info(f"MariaDB connected: {host}:{port}/{database}")
|
||||
self._connect()
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"mysql-connector-python is required for MariaDB support. "
|
||||
|
|
@ -124,6 +121,26 @@ class MariaDBConnection(DatabaseConnection):
|
|||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to connect to MariaDB: {e}")
|
||||
|
||||
def _connect(self):
|
||||
"""Establish or re-establish the MySQL connection."""
|
||||
self._conn = self._mysql.connect(
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
user=self._user,
|
||||
password=self._password,
|
||||
database=self._database,
|
||||
autocommit=True,
|
||||
)
|
||||
log.info(f"MariaDB connected: {self._host}:{self._port}/{self._database}")
|
||||
|
||||
def _ensure_connected(self):
|
||||
"""Reconnect if the connection has been lost."""
|
||||
try:
|
||||
self._conn.ping(reconnect=True, attempts=2, delay=1)
|
||||
except Exception:
|
||||
log.info("MariaDB connection lost, reconnecting...")
|
||||
self._connect()
|
||||
|
||||
def _prepare(self, query: str) -> str:
|
||||
"""Adapt a query for MariaDB: fix placeholders and reserved words."""
|
||||
import re
|
||||
|
|
@ -134,6 +151,7 @@ class MariaDBConnection(DatabaseConnection):
|
|||
return query
|
||||
|
||||
def execute(self, query: str, params: tuple = ()) -> Any:
|
||||
self._ensure_connected()
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
|
|
@ -141,17 +159,20 @@ class MariaDBConnection(DatabaseConnection):
|
|||
return cursor
|
||||
|
||||
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
||||
self._ensure_connected()
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor()
|
||||
cursor.executemany(query, params_list)
|
||||
|
||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
||||
self._ensure_connected()
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchone()
|
||||
|
||||
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
||||
self._ensure_connected()
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
|
|
|
|||
Loading…
Reference in a new issue