- 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>
340 lines
10 KiB
TypeScript
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);
|
|
});
|