dougbot/bridge/src/index.ts
roberts 9aa0abbf59 Phase 1+2: Doug connects, chats, brain loop (movement WIP)
- Hybrid Python/Node.js architecture with WebSocket bridge
- PySide6 desktop app with smoky blue futuristic theme
- bedrock-protocol connection (offline + Xbox Live auth + Realms)
- Ollama integration with lean persona prompt
- 40 personality traits (15 sliders + 23 quirks + 2 toggles)
- Chat working in-game with personality
- Brain loop with decision engine
- Movement code (needs mineflayer-bedrock for proper server-auth)
- Entity tracking framework
- RakNet protocol 11 patch for newer BDS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:30:39 -05:00

340 lines
10 KiB
TypeScript

/**
* DougBot Bridge - Entry Point
*
* Connects to Minecraft Bedrock servers (offline, online, or Realm)
* and exposes a WebSocket API for the Python controller.
*
* Usage:
* node dist/index.js --host <host> --port <port> --username <name> [--ws-port <ws_port>] [--offline]
* node dist/index.js --email <email> --host <host> --port <port> [--ws-port <ws_port>]
* node dist/index.js --email <email> --realm [--realm-id <id>] [--ws-port <ws_port>]
*/
import { BedrockClient, ClientOptions } from './client';
import { BridgeWSServer } from './ws_server';
import { ChatAction } from './actions/chat';
import { MovementAction } from './actions/movement';
import { EntityTracker } from './world/entity_tracker';
import { buildAuthOptions, buildRealmOptions, listRealms } from './auth';
import { createLogger, setLogLevel, LogLevel } from './utils/logger';
const log = createLogger('main');
// ============================================================
// Parse command line arguments
// ============================================================
function parseArgs(): Record<string, string> {
const args = process.argv.slice(2);
const parsed: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].substring(2);
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
parsed[key] = args[i + 1];
i++;
} else {
parsed[key] = 'true';
}
}
}
return parsed;
}
// ============================================================
// Main
// ============================================================
async function main(): Promise<void> {
const args = parseArgs();
if (args['debug'] === 'true') {
setLogLevel(LogLevel.DEBUG);
}
const wsPort = parseInt(args['ws-port'] || '8765', 10);
const connType = args['conn-type'] || (args['offline'] === 'true' ? 'offline' : (args['realm'] === 'true' ? 'realm' : 'online'));
log.info('DougBot Bridge starting', {
connType,
host: args['host'] || '(realm)',
port: args['port'] || '(realm)',
username: args['username'] || args['email'] || 'Doug',
wsPort,
});
// Create the WebSocket server
const wsServer = new BridgeWSServer(wsPort);
// Build client options based on connection type
let clientOptions: ClientOptions;
let bedrockClient: BedrockClient;
if (connType === 'offline') {
// ── Offline: Direct connection, no auth ──
clientOptions = {
host: args['host'] || '127.0.0.1',
port: parseInt(args['port'] || '19132', 10),
username: args['username'] || 'Doug',
offline: true,
};
} else if (connType === 'realm') {
// ── Realm: Xbox Live auth + Realm connection ──
const email = args['email'];
if (!email) {
log.error('Realm connection requires --email');
process.exit(1);
}
const authOpts = buildRealmOptions(
{
email,
onDeviceCode: (code, url) => {
// Emit device code as an event so Python GUI can display it
wsServer.emitEvent('auth_device_code', { code, url });
},
},
args['realm-id'] || undefined,
);
clientOptions = {
host: '', // Will be resolved by Realm auth
port: 0,
username: email,
offline: false,
extraOptions: authOpts,
};
} else {
// ── Online: Xbox Live auth + direct server ──
const email = args['email'];
if (!email) {
log.error('Online connection requires --email');
process.exit(1);
}
const authOpts = buildAuthOptions({
email,
onDeviceCode: (code, url) => {
wsServer.emitEvent('auth_device_code', { code, url });
},
});
clientOptions = {
host: args['host'] || '127.0.0.1',
port: parseInt(args['port'] || '19132', 10),
username: email,
offline: false,
extraOptions: authOpts,
};
}
bedrockClient = new BedrockClient(clientOptions);
const chatAction = new ChatAction(bedrockClient);
const movementAction = new MovementAction(bedrockClient);
movementAction.setOnArrival(() => {
wsServer.emitEvent('movement_complete', {});
});
const entityTracker = new EntityTracker();
// Wire up entity tracking from bedrock events
bedrockClient.on('entity_spawned', (data: any) => {
entityTracker.addEntity(data.entityId, data.type, data.position, data.name);
});
bedrockClient.on('entity_removed', (data: any) => {
entityTracker.removeEntity(data.entityId);
});
// ============================================================
// Wire up bridge action handler
// ============================================================
wsServer.setActionHandler(async (action, params) => {
switch (action) {
case 'status':
return {
status: 'success',
data: {
connected: bedrockClient.state.connected,
spawned: bedrockClient.state.spawned,
position: bedrockClient.state.position,
health: bedrockClient.state.health,
food: bedrockClient.state.food,
gameTime: bedrockClient.state.gameTime,
dayTime: bedrockClient.state.dayTime,
},
};
case 'connect':
try {
await bedrockClient.connect();
return { status: 'success', data: { message: 'Connecting to server...' } };
} catch (err: any) {
return { status: 'error', error: err.message };
}
case 'disconnect':
bedrockClient.disconnect();
return { status: 'success', data: { message: 'Disconnected' } };
case 'send_chat':
const chatResult = chatAction.sendChat(params.message || '');
return {
status: chatResult.success ? 'success' : 'error',
data: chatResult.success ? {} : undefined,
error: chatResult.error,
};
case 'get_position':
return {
status: 'success',
data: { position: bedrockClient.state.position },
};
case 'get_health':
return {
status: 'success',
data: { health: bedrockClient.state.health, food: bedrockClient.state.food },
};
case 'get_time':
return {
status: 'success',
data: { gameTime: bedrockClient.state.gameTime, dayTime: bedrockClient.state.dayTime },
};
case 'move_to':
// Use /tp for reliable movement (works on all server types)
try {
bedrockClient.teleportTo(params.x || 0, params.y || 0, params.z || 0);
return { status: 'success' };
} catch (err: any) {
return { status: 'error', error: err.message };
}
case 'move_relative':
try {
bedrockClient.teleportRelative(params.dx || 0, params.dy || 0, params.dz || 0);
return { status: 'success' };
} catch (err: any) {
return { status: 'error', error: err.message };
}
case 'send_command':
try {
bedrockClient.sendCommand(params.command || '');
return { status: 'success' };
} catch (err: any) {
return { status: 'error', error: err.message };
}
case 'stop':
movementAction.stop();
return { status: 'success' };
case 'look_at':
movementAction.lookAt({ x: params.x || 0, y: params.y || 0, z: params.z || 0 });
return { status: 'success' };
case 'get_nearby_entities':
const nearby = entityTracker.getNearbyEntities(
bedrockClient.state.position,
params.radius || 32,
);
return { status: 'success', data: { entities: nearby } };
case 'get_nearby_hostiles':
const hostiles = entityTracker.getNearbyHostiles(
bedrockClient.state.position,
params.radius || 16,
);
return { status: 'success', data: { hostiles } };
case 'get_players':
return {
status: 'success',
data: { players: entityTracker.getPlayerNames() },
};
case 'list_realms':
try {
const email = params.email;
if (!email) {
return { status: 'error', error: 'Email is required to list Realms' };
}
const realms = await listRealms({
email,
onDeviceCode: (code, url) => {
wsServer.emitEvent('auth_device_code', { code, url });
},
});
return { status: 'success', data: { realms } };
} catch (err: any) {
return { status: 'error', error: err.message };
}
default:
log.warn(`Unhandled action: ${action}`);
return {
status: 'error',
error: `Unknown action: ${action}`,
};
}
});
// ============================================================
// Forward Bedrock events to WebSocket
// ============================================================
const forwardEvents = [
'chat_message', 'health_changed', 'entity_spawned', 'entity_removed',
'player_joined', 'player_left', 'damage_taken', 'death',
'time_update', 'spawn_complete', 'connected', 'disconnected',
'error', 'respawn',
] as const;
for (const event of forwardEvents) {
bedrockClient.on(event, (data: any) => {
wsServer.emitEvent(event as any, data || {});
});
}
// ============================================================
// Start
// ============================================================
try {
await wsServer.start();
await bedrockClient.connect();
log.info('Bridge is running. Waiting for Python controller...');
} catch (err: any) {
log.error('Failed to start bridge', { error: err.message });
process.exit(1);
}
// Graceful shutdown
const shutdown = () => {
log.info('Shutting down...');
bedrockClient.disconnect();
wsServer.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('unhandledRejection', (reason: any) => {
log.error('Unhandled promise rejection', { error: reason?.message || String(reason), stack: reason?.stack || '' });
});
process.on('uncaughtException', (err) => {
log.error('Uncaught exception', { error: err.message, stack: err.stack });
shutdown();
});
}
main().catch((err) => {
log.error('Fatal error', { error: err.message });
process.exit(1);
});