/** * 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; } 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 { 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; } }