dougbot/bridge/src/client.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

365 lines
10 KiB
TypeScript

/**
* Bedrock protocol client wrapper.
* Manages the connection to a Minecraft Bedrock server and
* provides event hooks for the bridge to consume.
*/
import { createClient, Client } from 'bedrock-protocol';
import { EventEmitter } from 'events';
import { createLogger } from './utils/logger';
import { Vec3 } from './utils/vec3';
const log = createLogger('client');
export interface ClientOptions {
host: string;
port: number;
username: string;
offline: boolean;
version?: string;
/** Extra options merged into createClient (auth, realms, etc.) */
extraOptions?: Record<string, any>;
}
export interface ClientState {
connected: boolean;
spawned: boolean;
position: Vec3;
health: number;
food: number;
gameTime: number;
dayTime: number;
}
export class BedrockClient extends EventEmitter {
private client: Client | null = null;
private options: ClientOptions;
public state: ClientState;
constructor(options: ClientOptions) {
super();
this.options = options;
this.state = {
connected: false,
spawned: false,
position: { x: 0, y: 0, z: 0 },
health: 20,
food: 20,
gameTime: 0,
dayTime: 0,
};
}
async connect(): Promise<void> {
log.info(`Connecting to ${this.options.host}:${this.options.port} as ${this.options.username}`);
try {
const clientOpts: any = {
host: this.options.host,
port: this.options.port,
username: this.options.username,
offline: this.options.offline,
version: this.options.version || '26.10',
raknetBackend: 'jsp-raknet',
connectTimeout: 20000,
};
// Merge extra options (auth, realms, etc.)
if (this.options.extraOptions) {
Object.assign(clientOpts, this.options.extraOptions);
}
this.client = createClient(clientOpts);
this.setupEventHandlers();
log.info('Client created, waiting for spawn...');
} catch (err: any) {
log.error('Failed to create client', { error: err.message });
throw err;
}
}
private setupEventHandlers(): void {
if (!this.client) return;
// Connection events
this.client.on('join', () => {
log.info('Joined server');
this.state.connected = true;
this.emit('connected');
});
this.client.on('spawn', () => {
log.info('Spawned in world');
this.state.spawned = true;
// Critical: send initialization packets so the server treats us as a real player
// Without this, the bot is stuck in an immortal ghost state (issue #523)
const rawClient = this.client as any;
try {
const runtimeId = rawClient?.entityId ?? rawClient?.startGameData?.runtime_entity_id ?? 0n;
rawClient?.write('set_local_player_as_initialized', {
runtime_entity_id: BigInt(runtimeId),
});
log.info('Sent player initialization');
} catch (err: any) {
log.warn('Failed to send player init', { error: err.message });
}
this.emit('spawn_complete', { position: this.state.position });
});
this.client.on('close', () => {
log.info('Disconnected from server');
this.state.connected = false;
this.state.spawned = false;
this.emit('disconnected', { reason: 'connection_closed' });
});
this.client.on('error', (err: any) => {
const errorMsg = err?.message || String(err);
const errorStack = err?.stack || '';
const errorCode = err?.code || '';
log.error('Client error', { error: errorMsg, code: errorCode, stack: errorStack });
this.emit('error', { message: errorMsg, code: errorCode });
});
this.client.on('kick', (reason: any) => {
log.warn('Kicked from server', { reason });
this.state.connected = false;
this.emit('disconnected', { reason: `kicked: ${JSON.stringify(reason)}` });
});
// Chat messages
this.client.on('text', (packet: any) => {
const sender = packet.source_name || packet.xuid || 'Unknown';
const message = packet.message || '';
const type = packet.type || 'chat';
log.info('Chat received', { sender, message, type });
// Skip our own messages and system messages with no sender
if (sender === this.options.username) return;
if (!message || message.trim() === '') return;
this.emit('chat_message', { sender, message, type });
});
// Player position updates (our own position)
this.client.on('move_player', (packet: any) => {
if (packet.runtime_id === this.getRuntimeId()) {
this.state.position = {
x: packet.position.x,
y: packet.position.y,
z: packet.position.z,
};
}
});
// Respawn / position correction
this.client.on('respawn', (packet: any) => {
this.state.position = {
x: packet.position?.x ?? this.state.position.x,
y: packet.position?.y ?? this.state.position.y,
z: packet.position?.z ?? this.state.position.z,
};
log.info('Respawned', { position: this.state.position });
this.emit('respawn', { position: this.state.position });
});
// Health and food updates
this.client.on('update_attributes', (packet: any) => {
if (!packet.attributes) return;
for (const attr of packet.attributes) {
if (attr.name === 'minecraft:health') {
const oldHealth = this.state.health;
this.state.health = attr.current ?? attr.value ?? this.state.health;
if (this.state.health !== oldHealth) {
this.emit('health_changed', {
health: this.state.health,
food: this.state.food,
});
}
}
if (attr.name === 'minecraft:player.hunger') {
this.state.food = attr.current ?? attr.value ?? this.state.food;
}
}
});
// Time update
this.client.on('set_time', (packet: any) => {
this.state.gameTime = packet.time ?? this.state.gameTime;
this.state.dayTime = packet.time % 24000;
this.emit('time_update', {
gameTime: this.state.gameTime,
dayTime: this.state.dayTime,
});
});
// Player join/leave
this.client.on('player_list', (packet: any) => {
if (!packet.records?.records) return;
for (const record of packet.records.records) {
if (record.username === this.options.username) continue;
if (packet.records.type === 'add') {
this.emit('player_joined', { username: record.username });
} else if (packet.records.type === 'remove') {
this.emit('player_left', { username: record.username });
}
}
});
// Death
this.client.on('death_info', (packet: any) => {
log.info('Died', { message: packet.message });
this.emit('death', {
message: packet.message,
position: { ...this.state.position },
});
});
// Entity events
this.client.on('add_entity', (packet: any) => {
this.emit('entity_spawned', {
entityId: packet.runtime_id,
type: packet.entity_type,
position: packet.position,
});
});
this.client.on('add_player', (packet: any) => {
if (packet.username === this.options.username) return;
this.emit('entity_spawned', {
entityId: packet.runtime_id,
type: 'player',
name: packet.username,
position: packet.position,
});
});
this.client.on('remove_entity', (packet: any) => {
this.emit('entity_removed', {
entityId: packet.entity_id_self,
});
});
// Start position from start_game
this.client.on('start_game', (packet: any) => {
if (packet.player_position) {
// BDS encodes Y with a 32768 offset in start_game
let y = Number(packet.player_position.y);
if (y > 30000) y -= 32768;
this.state.position = {
x: Number(packet.player_position.x),
y: y,
z: Number(packet.player_position.z),
};
log.info('Start position', { position: this.state.position });
}
});
}
/**
* Send a chat message to the server.
*/
sendChat(message: string): void {
if (!this.client || !this.state.connected) {
log.warn('Cannot send chat: not connected');
return;
}
// Full text packet with all required fields for newer BDS
this.client.queue('text', {
needs_translation: false,
category: 'authored',
type: 'chat',
source_name: this.options.username,
message: message,
xuid: '',
platform_chat_id: '',
has_filtered_message: false,
});
log.info('Sent chat', { message: message.substring(0, 60) });
}
/**
* Execute a server command (requires OP).
*/
sendCommand(command: string): void {
if (!this.client || !this.state.connected) {
log.warn('Cannot send command: not connected');
return;
}
// Strip leading / if present
const cmd = command.startsWith('/') ? command.substring(1) : command;
try {
this.client.queue('command_request', {
command: cmd,
origin: {
type: 'player',
uuid: '00000000-0000-0000-0000-000000000000',
request_id: `cmd_${Date.now()}`,
player_entity_id: 0n,
},
internal: false,
version: '1',
});
} catch (err: any) {
log.error('Command failed', { command: cmd, error: err.message });
}
}
/**
* Teleport to a position using /tp command.
*/
teleportTo(x: number, y: number, z: number): void {
this.sendCommand(`tp @s ${x.toFixed(2)} ${y.toFixed(2)} ${z.toFixed(2)}`);
}
/**
* Teleport relative to current position.
*/
teleportRelative(dx: number, dy: number, dz: number): void {
this.sendCommand(`tp @s ~${dx.toFixed(2)} ~${dy.toFixed(2)} ~${dz.toFixed(2)}`);
}
/**
* Get the client's runtime entity ID.
*/
private getRuntimeId(): bigint | number | undefined {
// bedrock-protocol stores this internally
return (this.client as any)?.entityId;
}
/**
* Get the raw bedrock-protocol client for advanced operations.
*/
getRawClient(): Client | null {
return this.client;
}
/**
* Disconnect from the server.
*/
disconnect(): void {
if (this.client) {
log.info('Disconnecting...');
this.client.disconnect();
this.client = null;
this.state.connected = false;
this.state.spawned = false;
}
}
/**
* Check if the client is connected and spawned.
*/
isReady(): boolean {
return this.state.connected && this.state.spawned;
}
}