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:
roberts 2026-03-30 11:56:56 -05:00
parent 9aa0abbf59
commit d0a96ce028
6 changed files with 9483 additions and 153 deletions

8941
bridge/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -47,37 +47,25 @@ 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...")
# Check if node_modules exists
if not (bridge_dir / "node_modules").exists():
log.info("Installing bridge dependencies...")
proc = QProcess()
proc.setWorkingDirectory(str(bridge_dir))
proc.start("npm", ["install"])
proc.waitForFinished(120000) # 2 minutes
raise FileNotFoundError(f"Bridge entry point not found: {entry_file}")
if proc.exitCode() != 0:
stderr = proc.readAllStandardError().data().decode()
raise RuntimeError(f"npm install failed: {stderr}")
# Compile TypeScript
# Check if node_modules exists
if not (bridge_dir / "node_modules").exists():
log.info("Installing bridge dependencies...")
proc = QProcess()
proc.setWorkingDirectory(str(bridge_dir))
proc.start("npx", ["tsc"])
proc.waitForFinished(60000) # 1 minute
proc.start("npm", ["install"])
proc.waitForFinished(120000) # 2 minutes
if proc.exitCode() != 0:
stderr = proc.readAllStandardError().data().decode()
raise RuntimeError(f"TypeScript compilation failed: {stderr}")
log.info("Bridge compiled successfully")
raise RuntimeError(f"npm install failed: {stderr}")
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,

View file

@ -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"

View file

@ -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)