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",
|
"name": "dougbot-bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Minecraft Bedrock protocol bridge for DougBot",
|
"description": "Minecraft Bedrock bridge for DougBot (mineflayer-bedrock)",
|
||||||
"main": "dist/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node patches/patch-raknet.js",
|
"postinstall": "node patches/patch-raknet.js",
|
||||||
"build": "tsc",
|
"start": "node --experimental-strip-types --disable-warning=ExperimentalWarning src/index.js"
|
||||||
"dev": "tsc --watch",
|
|
||||||
"start": "node dist/index.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"bedrock-protocol": "^3.55.0",
|
||||||
"minecraft-data": "^3.108.0",
|
"minecraft-data": "^3.108.0",
|
||||||
"uuid": "^11.1.0",
|
"prismarine-physics": "^1.9.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0",
|
||||||
|
"vec3": "^0.1.10",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"engines": {
|
||||||
"@types/node": "^22.0.0",
|
"node": ">=22.0.0"
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@types/ws": "^8.5.0",
|
|
||||||
"typescript": "^5.7.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,37 +47,25 @@ class NodeManager(QObject):
|
||||||
"is in the project root with package.json."
|
"is in the project root with package.json."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_built(self) -> Path:
|
def _ensure_ready(self) -> Path:
|
||||||
"""Ensure the bridge TypeScript is compiled."""
|
"""Ensure the bridge is ready to run (dependencies installed)."""
|
||||||
bridge_dir = self._find_bridge_dir()
|
bridge_dir = self._find_bridge_dir()
|
||||||
dist_dir = bridge_dir / "dist"
|
entry_file = bridge_dir / "src" / "index.js"
|
||||||
entry_file = dist_dir / "index.js"
|
|
||||||
|
|
||||||
if not entry_file.exists():
|
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...")
|
|
||||||
proc = QProcess()
|
|
||||||
proc.setWorkingDirectory(str(bridge_dir))
|
|
||||||
proc.start("npm", ["install"])
|
|
||||||
proc.waitForFinished(120000) # 2 minutes
|
|
||||||
|
|
||||||
if proc.exitCode() != 0:
|
# Check if node_modules exists
|
||||||
stderr = proc.readAllStandardError().data().decode()
|
if not (bridge_dir / "node_modules").exists():
|
||||||
raise RuntimeError(f"npm install failed: {stderr}")
|
log.info("Installing bridge dependencies...")
|
||||||
|
|
||||||
# Compile TypeScript
|
|
||||||
proc = QProcess()
|
proc = QProcess()
|
||||||
proc.setWorkingDirectory(str(bridge_dir))
|
proc.setWorkingDirectory(str(bridge_dir))
|
||||||
proc.start("npx", ["tsc"])
|
proc.start("npm", ["install"])
|
||||||
proc.waitForFinished(60000) # 1 minute
|
proc.waitForFinished(120000) # 2 minutes
|
||||||
|
|
||||||
if proc.exitCode() != 0:
|
if proc.exitCode() != 0:
|
||||||
stderr = proc.readAllStandardError().data().decode()
|
stderr = proc.readAllStandardError().data().decode()
|
||||||
raise RuntimeError(f"TypeScript compilation failed: {stderr}")
|
raise RuntimeError(f"npm install failed: {stderr}")
|
||||||
|
|
||||||
log.info("Bridge compiled successfully")
|
|
||||||
|
|
||||||
self._bridge_dir = bridge_dir
|
self._bridge_dir = bridge_dir
|
||||||
return entry_file
|
return entry_file
|
||||||
|
|
@ -111,26 +99,34 @@ class NodeManager(QObject):
|
||||||
self._ws_port = ws_port
|
self._ws_port = ws_port
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entry_file = self._ensure_built()
|
entry_file = self._ensure_ready()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_occurred.emit(str(e))
|
self.error_occurred.emit(str(e))
|
||||||
return
|
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
|
node_path = None
|
||||||
fnm_node = Path.home() / ".local" / "share" / "fnm" / "node-versions" / "v20.20.2" / "installation" / "bin" / "node"
|
# Check fnm-managed Node 22 first
|
||||||
if fnm_node.exists():
|
fnm_base = Path.home() / ".local" / "share" / "fnm" / "node-versions"
|
||||||
node_path = str(fnm_node)
|
if fnm_base.exists():
|
||||||
log.info(f"Using fnm Node 20: {node_path}")
|
for version_dir in sorted(fnm_base.iterdir(), reverse=True):
|
||||||
else:
|
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")
|
node_path = shutil.which("node")
|
||||||
|
|
||||||
if not node_path:
|
if not node_path:
|
||||||
self.error_occurred.emit("Node.js not found. Please install Node.js.")
|
self.error_occurred.emit("Node.js not found. Please install Node.js.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build arguments based on connection type
|
# Build arguments — need --experimental-strip-types for .mts files
|
||||||
args = [
|
args = [
|
||||||
|
"--experimental-strip-types",
|
||||||
|
"--disable-warning=ExperimentalWarning",
|
||||||
str(entry_file),
|
str(entry_file),
|
||||||
"--ws-port", str(ws_port),
|
"--ws-port", str(ws_port),
|
||||||
"--conn-type", conn_type,
|
"--conn-type", conn_type,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Doug's Brain — the autonomous decision loop.
|
Doug's Brain — the autonomous decision loop.
|
||||||
Runs every 2 seconds and decides what Doug should do next.
|
Runs every 2 seconds and decides what Doug should do next.
|
||||||
|
Uses mineflayer pathfinder for real movement.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
@ -58,6 +59,8 @@ class DougBrain(QObject):
|
||||||
"""Stop the brain loop."""
|
"""Stop the brain loop."""
|
||||||
self._running = False
|
self._running = False
|
||||||
self._tick_timer.stop()
|
self._tick_timer.stop()
|
||||||
|
# Tell bridge to stop moving
|
||||||
|
self._ws.send_request("stop", {})
|
||||||
log.info("Brain stopped")
|
log.info("Brain stopped")
|
||||||
|
|
||||||
def update_from_event(self, event: str, data: dict):
|
def update_from_event(self, event: str, data: dict):
|
||||||
|
|
@ -79,6 +82,11 @@ class DougBrain(QObject):
|
||||||
self._is_moving = False
|
self._is_moving = False
|
||||||
self._current_action = "idle"
|
self._current_action = "idle"
|
||||||
self._idle_since = time.time()
|
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):
|
def _tick(self):
|
||||||
"""One brain tick — observe, decide, act."""
|
"""One brain tick — observe, decide, act."""
|
||||||
|
|
@ -88,12 +96,13 @@ class DougBrain(QObject):
|
||||||
|
|
||||||
self._ticks_since_chat += 1
|
self._ticks_since_chat += 1
|
||||||
|
|
||||||
# If movement is taking too long (> 15 sec), cancel it
|
# If movement is taking too long (> 30 sec), cancel it
|
||||||
if self._is_moving and (time.time() - self._action_start_time > 15):
|
if self._is_moving and (time.time() - self._action_start_time > 30):
|
||||||
|
self._ws.send_request("stop", {})
|
||||||
self._is_moving = False
|
self._is_moving = False
|
||||||
self._current_action = "idle"
|
self._current_action = "idle"
|
||||||
self._idle_since = time.time()
|
self._idle_since = time.time()
|
||||||
log.debug("Movement timed out, going idle")
|
log.debug("Movement timed out, stopping")
|
||||||
|
|
||||||
# Request current status from bridge
|
# Request current status from bridge
|
||||||
if not self._pending_status:
|
if not self._pending_status:
|
||||||
|
|
@ -124,7 +133,7 @@ class DougBrain(QObject):
|
||||||
entities = response.data.get("entities", [])
|
entities = response.data.get("entities", [])
|
||||||
self._nearby_players = [
|
self._nearby_players = [
|
||||||
e for e in entities
|
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 = [
|
self._nearby_hostiles = [
|
||||||
e for e in entities if e.get("isHostile", False)
|
e for e in entities if e.get("isHostile", False)
|
||||||
|
|
@ -133,41 +142,14 @@ class DougBrain(QObject):
|
||||||
def _decide(self):
|
def _decide(self):
|
||||||
"""Core decision logic — what should Doug do right now?"""
|
"""Core decision logic — what should Doug do right now?"""
|
||||||
|
|
||||||
# Continue walking if we have steps left
|
# Don't interrupt current actions (pathfinder is handling it)
|
||||||
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
|
|
||||||
if self._is_moving:
|
if self._is_moving:
|
||||||
return
|
return
|
||||||
|
|
||||||
idle_duration = time.time() - self._idle_since
|
idle_duration = time.time() - self._idle_since
|
||||||
|
|
||||||
# Priority 1: Flee from CLOSE hostiles (within 8 blocks) when hurt
|
# 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:
|
if close_hostiles and self._health < 14:
|
||||||
self._flee_from_hostile(close_hostiles[0])
|
self._flee_from_hostile(close_hostiles[0])
|
||||||
return
|
return
|
||||||
|
|
@ -178,22 +160,19 @@ class DougBrain(QObject):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Priority 3: Look around when idle
|
# 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()
|
self._look_around()
|
||||||
return
|
return
|
||||||
|
|
||||||
def _distance_to(self, entity: dict) -> float:
|
def _distance_to(self, entity: dict) -> float:
|
||||||
"""Distance from Doug to an entity."""
|
"""Distance from Doug to an entity."""
|
||||||
epos = entity.get("position", {})
|
return entity.get("distance", 99)
|
||||||
dx = self._position["x"] - epos.get("x", 0)
|
|
||||||
dz = self._position["z"] - epos.get("z", 0)
|
|
||||||
return math.sqrt(dx * dx + dz * dz)
|
|
||||||
|
|
||||||
def _wander(self):
|
def _wander(self):
|
||||||
"""Walk to a random nearby position using small teleport steps."""
|
"""Walk to a random nearby position using pathfinder."""
|
||||||
# Pick a random direction and distance (3-8 blocks)
|
# Pick a random direction and distance (5-15 blocks)
|
||||||
angle = random.uniform(0, 2 * math.pi)
|
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_x = self._position["x"] + math.cos(angle) * dist
|
||||||
target_z = self._position["z"] + math.sin(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"]
|
dx = target_x - self._spawn_pos["x"]
|
||||||
dz = target_z - self._spawn_pos["z"]
|
dz = target_z - self._spawn_pos["z"]
|
||||||
if math.sqrt(dx * dx + dz * dz) > 50:
|
if math.sqrt(dx * dx + dz * dz) > 50:
|
||||||
|
# Walk back toward spawn
|
||||||
angle = math.atan2(
|
angle = math.atan2(
|
||||||
self._spawn_pos["z"] - self._position["z"],
|
self._spawn_pos["z"] - self._position["z"],
|
||||||
self._spawn_pos["x"] - self._position["x"],
|
self._spawn_pos["x"] - self._position["x"],
|
||||||
)
|
)
|
||||||
target_x = self._position["x"] + math.cos(angle) * 6
|
target_x = self._position["x"] + math.cos(angle) * 8
|
||||||
target_z = self._position["z"] + math.sin(angle) * 6
|
target_z = self._position["z"] + math.sin(angle) * 8
|
||||||
log.debug("Wandering back toward spawn")
|
log.debug("Wandering back toward spawn")
|
||||||
|
|
||||||
# Use small relative teleports (~1 block per step) to simulate walking
|
# Use pathfinder to walk there
|
||||||
dx = target_x - self._position["x"]
|
self._ws.send_request("move_to", {
|
||||||
dz = target_z - self._position["z"]
|
"x": target_x,
|
||||||
total_dist = math.sqrt(dx * dx + dz * dz)
|
"y": self._position["y"],
|
||||||
if total_dist < 0.5:
|
"z": target_z,
|
||||||
return
|
"range": 2,
|
||||||
|
|
||||||
# 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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# 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._is_moving = True
|
||||||
self._action_start_time = time.time()
|
self._action_start_time = time.time()
|
||||||
self._current_action = "wandering"
|
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):
|
def _look_around(self):
|
||||||
"""Look at a random direction."""
|
"""Look at a random direction."""
|
||||||
|
|
@ -254,24 +215,24 @@ class DougBrain(QObject):
|
||||||
})
|
})
|
||||||
|
|
||||||
def _flee_from_hostile(self, hostile: dict):
|
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", {})
|
hpos = hostile.get("position", {})
|
||||||
dx = self._position["x"] - hpos.get("x", 0)
|
dx = self._position["x"] - hpos.get("x", 0)
|
||||||
dz = self._position["z"] - hpos.get("z", 0)
|
dz = self._position["z"] - hpos.get("z", 0)
|
||||||
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
||||||
|
|
||||||
# Sprint 3 blocks away in opposite direction
|
# Pathfind 10 blocks away from mob
|
||||||
flee_dx = (dx / dist) * 3
|
flee_dist = 10
|
||||||
flee_dz = (dz / dist) * 3
|
flee_x = self._position["x"] + (dx / dist) * flee_dist
|
||||||
|
flee_z = self._position["z"] + (dz / dist) * flee_dist
|
||||||
|
|
||||||
self._ws.send_request("move_relative", {
|
self._ws.send_request("move_to", {
|
||||||
"dx": flee_dx,
|
"x": flee_x,
|
||||||
"dy": 0,
|
"y": self._position["y"],
|
||||||
"dz": flee_dz,
|
"z": flee_z,
|
||||||
|
"range": 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
self._position["x"] += flee_dx
|
|
||||||
self._position["z"] += flee_dz
|
|
||||||
self._is_moving = True
|
self._is_moving = True
|
||||||
self._action_start_time = time.time()
|
self._action_start_time = time.time()
|
||||||
self._current_action = "fleeing"
|
self._current_action = "fleeing"
|
||||||
|
|
|
||||||
|
|
@ -103,19 +103,16 @@ class MariaDBConnection(DatabaseConnection):
|
||||||
**kwargs):
|
**kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._db_type = "mariadb"
|
self._db_type = "mariadb"
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._user = user
|
||||||
|
self._password = password
|
||||||
|
self._database = database
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
self._conn = mysql.connector.connect(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
user=user,
|
|
||||||
password=password,
|
|
||||||
database=database,
|
|
||||||
autocommit=True,
|
|
||||||
)
|
|
||||||
self._mysql = mysql.connector
|
self._mysql = mysql.connector
|
||||||
log.info(f"MariaDB connected: {host}:{port}/{database}")
|
self._connect()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"mysql-connector-python is required for MariaDB support. "
|
"mysql-connector-python is required for MariaDB support. "
|
||||||
|
|
@ -124,6 +121,26 @@ class MariaDBConnection(DatabaseConnection):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to connect to MariaDB: {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:
|
def _prepare(self, query: str) -> str:
|
||||||
"""Adapt a query for MariaDB: fix placeholders and reserved words."""
|
"""Adapt a query for MariaDB: fix placeholders and reserved words."""
|
||||||
import re
|
import re
|
||||||
|
|
@ -134,6 +151,7 @@ class MariaDBConnection(DatabaseConnection):
|
||||||
return query
|
return query
|
||||||
|
|
||||||
def execute(self, query: str, params: tuple = ()) -> Any:
|
def execute(self, query: str, params: tuple = ()) -> Any:
|
||||||
|
self._ensure_connected()
|
||||||
query = self._prepare(query)
|
query = self._prepare(query)
|
||||||
cursor = self._conn.cursor(dictionary=True)
|
cursor = self._conn.cursor(dictionary=True)
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
@ -141,17 +159,20 @@ class MariaDBConnection(DatabaseConnection):
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
||||||
|
self._ensure_connected()
|
||||||
query = self._prepare(query)
|
query = self._prepare(query)
|
||||||
cursor = self._conn.cursor()
|
cursor = self._conn.cursor()
|
||||||
cursor.executemany(query, params_list)
|
cursor.executemany(query, params_list)
|
||||||
|
|
||||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
||||||
|
self._ensure_connected()
|
||||||
query = self._prepare(query)
|
query = self._prepare(query)
|
||||||
cursor = self._conn.cursor(dictionary=True)
|
cursor = self._conn.cursor(dictionary=True)
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
return cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
||||||
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
||||||
|
self._ensure_connected()
|
||||||
query = self._prepare(query)
|
query = self._prepare(query)
|
||||||
cursor = self._conn.cursor(dictionary=True)
|
cursor = self._conn.cursor(dictionary=True)
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue