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