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>
This commit is contained in:
parent
13f5c84069
commit
9aa0abbf59
8 changed files with 721 additions and 5 deletions
189
bridge/src/actions/movement.ts
Normal file
189
bridge/src/actions/movement.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* Movement actions — walk to position, look at, jump.
|
||||||
|
* Uses player_auth_input for server-authoritative movement (works on Realms).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BedrockClient } from '../client';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('actions:movement');
|
||||||
|
|
||||||
|
interface Vec3 {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default input flags — all false
|
||||||
|
const DEFAULT_FLAGS: Record<string, boolean> = {
|
||||||
|
ascend: false, descend: false, north_jump: false, jump_down: false,
|
||||||
|
sprint_down: false, change_height: false, jumping: false,
|
||||||
|
auto_jumping_in_water: false, sneaking: false, sneak_down: false,
|
||||||
|
up: false, down: false, left: false, right: false,
|
||||||
|
up_left: false, up_right: false, want_up: false, want_down: false,
|
||||||
|
want_down_slow: false, want_up_slow: false, sprinting: false,
|
||||||
|
ascend_block: false, descend_block: false, sneak_toggle_down: false,
|
||||||
|
persist_sneak: false, start_sprinting: false, stop_sprinting: false,
|
||||||
|
start_sneaking: false, stop_sneaking: false, start_swimming: false,
|
||||||
|
stop_swimming: false, start_jumping: false, start_gliding: false,
|
||||||
|
stop_gliding: false, item_interact: false, block_action: false,
|
||||||
|
item_stack_request: false, handled_teleport: false, emoting: false,
|
||||||
|
client_predicted_vehicle: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MovementAction {
|
||||||
|
private client: BedrockClient;
|
||||||
|
private moveInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private targetPos: Vec3 | null = null;
|
||||||
|
private isMoving: boolean = false;
|
||||||
|
private onArrival: (() => void) | null = null;
|
||||||
|
private tickCounter: bigint = 0n;
|
||||||
|
|
||||||
|
constructor(client: BedrockClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set callback for when movement completes */
|
||||||
|
setOnArrival(callback: () => void) {
|
||||||
|
this.onArrival = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a player_auth_input packet (server-authoritative movement).
|
||||||
|
*/
|
||||||
|
private sendAuthInput(position: Vec3, yaw: number, moveX: number, moveZ: number, flags: Record<string, boolean> = {}): boolean {
|
||||||
|
const rawClient = this.client.getRawClient();
|
||||||
|
if (!rawClient) return false;
|
||||||
|
|
||||||
|
this.tickCounter++;
|
||||||
|
|
||||||
|
const mergedFlags = { ...DEFAULT_FLAGS, ...flags };
|
||||||
|
|
||||||
|
try {
|
||||||
|
rawClient.write('player_auth_input', {
|
||||||
|
pitch: 0.0,
|
||||||
|
yaw: yaw,
|
||||||
|
position: { x: position.x, y: position.y, z: position.z },
|
||||||
|
move_vector: { x: moveX, z: moveZ },
|
||||||
|
head_yaw: yaw,
|
||||||
|
input_data: mergedFlags,
|
||||||
|
input_mode: 'mouse',
|
||||||
|
play_mode: 'normal',
|
||||||
|
interaction_model: 'touch',
|
||||||
|
interact_rotation: { x: 0.0, z: 0.0 },
|
||||||
|
tick: this.tickCounter,
|
||||||
|
delta: { x: 0.0, y: 0.0, z: 0.0 },
|
||||||
|
analogue_move_vector: { x: moveX, z: moveZ },
|
||||||
|
camera_orientation: { x: 0.0, y: 0.0, z: 0.0 },
|
||||||
|
raw_move_vector: { x: moveX, z: moveZ },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error('Auth input failed', { error: e.message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk toward a target position using player_auth_input.
|
||||||
|
*/
|
||||||
|
walkTo(target: Vec3, speed: number = 4.317): { success: boolean; error?: string } {
|
||||||
|
if (!this.client.isReady()) {
|
||||||
|
return { success: false, error: 'Not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stop();
|
||||||
|
this.targetPos = target;
|
||||||
|
this.isMoving = true;
|
||||||
|
|
||||||
|
const TICK_MS = 50; // 20 ticks per second
|
||||||
|
const SPEED_PER_TICK = speed / 20;
|
||||||
|
const ARRIVAL_THRESHOLD = 1.5;
|
||||||
|
|
||||||
|
this.moveInterval = setInterval(() => {
|
||||||
|
if (!this.targetPos || !this.isMoving) {
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = this.client.state.position;
|
||||||
|
const dx = this.targetPos.x - pos.x;
|
||||||
|
const dz = this.targetPos.z - pos.z;
|
||||||
|
const distXZ = Math.sqrt(dx * dx + dz * dz);
|
||||||
|
|
||||||
|
if (distXZ < ARRIVAL_THRESHOLD) {
|
||||||
|
log.info('Arrived at target');
|
||||||
|
const cb = this.onArrival;
|
||||||
|
this.stop();
|
||||||
|
if (cb) cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize direction
|
||||||
|
const nx = dx / distXZ;
|
||||||
|
const nz = dz / distXZ;
|
||||||
|
|
||||||
|
// New position
|
||||||
|
const newX = pos.x + nx * SPEED_PER_TICK;
|
||||||
|
const newZ = pos.z + nz * SPEED_PER_TICK;
|
||||||
|
|
||||||
|
// Calculate yaw (look direction) — Minecraft yaw: 0 = +Z, 90 = -X
|
||||||
|
const yaw = Math.atan2(-nx, nz) * (180 / Math.PI);
|
||||||
|
|
||||||
|
// Send auth input with 'up' flag (forward movement)
|
||||||
|
const sent = this.sendAuthInput(
|
||||||
|
{ x: newX, y: pos.y, z: newZ },
|
||||||
|
yaw,
|
||||||
|
nx, // move_vector x component
|
||||||
|
nz, // move_vector z component
|
||||||
|
{ up: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
// Update our local state
|
||||||
|
this.client.state.position.x = newX;
|
||||||
|
this.client.state.position.z = newZ;
|
||||||
|
} else {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
|
||||||
|
log.info('Walking to', { target });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look at a specific position.
|
||||||
|
*/
|
||||||
|
lookAt(target: Vec3): { success: boolean } {
|
||||||
|
if (!this.client.isReady()) return { success: false };
|
||||||
|
|
||||||
|
const pos = this.client.state.position;
|
||||||
|
const dx = target.x - pos.x;
|
||||||
|
const dz = target.z - pos.z;
|
||||||
|
|
||||||
|
const yaw = Math.atan2(-dx, dz) * (180 / Math.PI);
|
||||||
|
|
||||||
|
// Send a single auth input with no movement
|
||||||
|
return { success: this.sendAuthInput(pos, yaw, 0, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all movement.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.moveInterval) {
|
||||||
|
clearInterval(this.moveInterval);
|
||||||
|
this.moveInterval = null;
|
||||||
|
}
|
||||||
|
this.isMoving = false;
|
||||||
|
this.targetPos = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently moving.
|
||||||
|
*/
|
||||||
|
getIsMoving(): boolean {
|
||||||
|
return this.isMoving;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,20 @@ export class BedrockClient extends EventEmitter {
|
||||||
this.client.on('spawn', () => {
|
this.client.on('spawn', () => {
|
||||||
log.info('Spawned in world');
|
log.info('Spawned in world');
|
||||||
this.state.spawned = true;
|
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.emit('spawn_complete', { position: this.state.position });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -234,10 +248,13 @@ export class BedrockClient extends EventEmitter {
|
||||||
// Start position from start_game
|
// Start position from start_game
|
||||||
this.client.on('start_game', (packet: any) => {
|
this.client.on('start_game', (packet: any) => {
|
||||||
if (packet.player_position) {
|
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 = {
|
this.state.position = {
|
||||||
x: packet.player_position.x,
|
x: Number(packet.player_position.x),
|
||||||
y: packet.player_position.y,
|
y: y,
|
||||||
z: packet.player_position.z,
|
z: Number(packet.player_position.z),
|
||||||
};
|
};
|
||||||
log.info('Start position', { position: this.state.position });
|
log.info('Start position', { position: this.state.position });
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +285,49 @@ export class BedrockClient extends EventEmitter {
|
||||||
log.info('Sent chat', { message: message.substring(0, 60) });
|
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.
|
* Get the client's runtime entity ID.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
import { BedrockClient, ClientOptions } from './client';
|
import { BedrockClient, ClientOptions } from './client';
|
||||||
import { BridgeWSServer } from './ws_server';
|
import { BridgeWSServer } from './ws_server';
|
||||||
import { ChatAction } from './actions/chat';
|
import { ChatAction } from './actions/chat';
|
||||||
|
import { MovementAction } from './actions/movement';
|
||||||
|
import { EntityTracker } from './world/entity_tracker';
|
||||||
import { buildAuthOptions, buildRealmOptions, listRealms } from './auth';
|
import { buildAuthOptions, buildRealmOptions, listRealms } from './auth';
|
||||||
import { createLogger, setLogLevel, LogLevel } from './utils/logger';
|
import { createLogger, setLogLevel, LogLevel } from './utils/logger';
|
||||||
|
|
||||||
|
|
@ -130,6 +132,19 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
bedrockClient = new BedrockClient(clientOptions);
|
bedrockClient = new BedrockClient(clientOptions);
|
||||||
const chatAction = new ChatAction(bedrockClient);
|
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
|
// Wire up bridge action handler
|
||||||
|
|
@ -189,6 +204,59 @@ async function main(): Promise<void> {
|
||||||
data: { gameTime: bedrockClient.state.gameTime, dayTime: bedrockClient.state.dayTime },
|
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':
|
case 'list_realms':
|
||||||
try {
|
try {
|
||||||
const email = params.email;
|
const email = params.email;
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,8 @@ export type EventType =
|
||||||
| 'connected'
|
| 'connected'
|
||||||
| 'disconnected'
|
| 'disconnected'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'auth_device_code';
|
| 'auth_device_code'
|
||||||
|
| 'movement_complete';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Data Structures
|
// Data Structures
|
||||||
|
|
|
||||||
95
bridge/src/world/entity_tracker.ts
Normal file
95
bridge/src/world/entity_tracker.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* Tracks nearby entities — players, mobs, items.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('world:entities');
|
||||||
|
|
||||||
|
export interface TrackedEntity {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
position: { x: number; y: number; z: number };
|
||||||
|
health?: number;
|
||||||
|
isHostile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOSTILE_MOBS = new Set([
|
||||||
|
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:spider', 'minecraft:creeper',
|
||||||
|
'minecraft:enderman', 'minecraft:witch', 'minecraft:blaze', 'minecraft:ghast',
|
||||||
|
'minecraft:slime', 'minecraft:phantom', 'minecraft:drowned', 'minecraft:husk',
|
||||||
|
'minecraft:stray', 'minecraft:wither_skeleton', 'minecraft:pillager',
|
||||||
|
'minecraft:vindicator', 'minecraft:evoker', 'minecraft:ravager',
|
||||||
|
'zombie', 'skeleton', 'spider', 'creeper', 'enderman', 'witch',
|
||||||
|
'blaze', 'ghast', 'slime', 'phantom', 'drowned', 'husk', 'stray',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class EntityTracker {
|
||||||
|
private entities: Map<string, TrackedEntity> = new Map();
|
||||||
|
private players: Map<string, TrackedEntity> = new Map();
|
||||||
|
|
||||||
|
addEntity(id: string | bigint | number, type: string, position: any, name?: string): void {
|
||||||
|
const sid = String(id);
|
||||||
|
const isHostile = HOSTILE_MOBS.has(type) || HOSTILE_MOBS.has(type.replace('minecraft:', ''));
|
||||||
|
const entity: TrackedEntity = {
|
||||||
|
id: sid,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
position: { x: position?.x || 0, y: position?.y || 0, z: position?.z || 0 },
|
||||||
|
isHostile,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'player') {
|
||||||
|
this.players.set(name || sid, entity);
|
||||||
|
}
|
||||||
|
this.entities.set(sid, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition(id: string | bigint | number, position: any): void {
|
||||||
|
const entity = this.entities.get(String(id));
|
||||||
|
if (entity && position) {
|
||||||
|
entity.position = { x: position.x || 0, y: position.y || 0, z: position.z || 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEntity(id: string | bigint | number): void {
|
||||||
|
const sid = String(id);
|
||||||
|
const entity = this.entities.get(sid);
|
||||||
|
if (entity?.type === 'player' && entity.name) {
|
||||||
|
this.players.delete(entity.name);
|
||||||
|
}
|
||||||
|
this.entities.delete(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNearbyEntities(pos: { x: number; y: number; z: number }, radius: number = 32): TrackedEntity[] {
|
||||||
|
const result: TrackedEntity[] = [];
|
||||||
|
for (const entity of this.entities.values()) {
|
||||||
|
const dx = entity.position.x - pos.x;
|
||||||
|
const dy = entity.position.y - pos.y;
|
||||||
|
const dz = entity.position.z - pos.z;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
if (dist <= radius) {
|
||||||
|
result.push(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNearbyHostiles(pos: { x: number; y: number; z: number }, radius: number = 16): TrackedEntity[] {
|
||||||
|
return this.getNearbyEntities(pos, radius).filter(e => e.isHostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayers(): TrackedEntity[] {
|
||||||
|
return Array.from(this.players.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerNames(): string[] {
|
||||||
|
return Array.from(this.players.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entities.clear();
|
||||||
|
this.players.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,7 +93,8 @@ class BridgeWSClient(QObject):
|
||||||
|
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if connected to the bridge."""
|
"""Check if connected to the bridge."""
|
||||||
return self._ws.state() == QWebSocket.SocketState.ConnectedState # type: ignore
|
from PySide6.QtNetwork import QAbstractSocket
|
||||||
|
return self._ws.state() == QAbstractSocket.SocketState.ConnectedState
|
||||||
|
|
||||||
def _on_connected(self) -> None:
|
def _on_connected(self) -> None:
|
||||||
log.info("Connected to bridge WebSocket")
|
log.info("Connected to bridge WebSocket")
|
||||||
|
|
|
||||||
286
dougbot/core/brain.py
Normal file
286
dougbot/core/brain.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""
|
||||||
|
Doug's Brain — the autonomous decision loop.
|
||||||
|
Runs every 2 seconds and decides what Doug should do next.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from PySide6.QtCore import QObject, QTimer, Signal
|
||||||
|
|
||||||
|
from dougbot.bridge.ws_client import BridgeWSClient
|
||||||
|
from dougbot.bridge.protocol import ResponseMessage
|
||||||
|
from dougbot.utils.logging import get_logger
|
||||||
|
|
||||||
|
log = get_logger("core.brain")
|
||||||
|
|
||||||
|
|
||||||
|
class DougBrain(QObject):
|
||||||
|
"""Autonomous decision engine. Ticks every 2 seconds."""
|
||||||
|
|
||||||
|
# Signal for chat messages the brain wants to send
|
||||||
|
wants_to_chat = Signal(str) # message
|
||||||
|
|
||||||
|
def __init__(self, ws_client: BridgeWSClient, doug_name: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._ws = ws_client
|
||||||
|
self._doug_name = doug_name
|
||||||
|
self._tick_timer = QTimer(self)
|
||||||
|
self._tick_timer.timeout.connect(self._tick)
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# State
|
||||||
|
self._position = {"x": 0, "y": 0, "z": 0}
|
||||||
|
self._health = 20
|
||||||
|
self._food = 20
|
||||||
|
self._day_time = 0
|
||||||
|
self._nearby_players: list[dict] = []
|
||||||
|
self._nearby_hostiles: list[dict] = []
|
||||||
|
self._is_moving = False
|
||||||
|
self._current_action = "idle"
|
||||||
|
self._action_start_time = 0.0
|
||||||
|
self._ticks_since_chat = 0
|
||||||
|
self._idle_since = time.time()
|
||||||
|
self._spawn_pos = {"x": 0, "y": 0, "z": 0}
|
||||||
|
self._has_spawn = False
|
||||||
|
|
||||||
|
# State request tracking
|
||||||
|
self._pending_status = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the brain loop."""
|
||||||
|
self._running = True
|
||||||
|
self._idle_since = time.time()
|
||||||
|
self._tick_timer.start(2000) # Every 2 seconds
|
||||||
|
log.info("Brain started — Doug is thinking")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the brain loop."""
|
||||||
|
self._running = False
|
||||||
|
self._tick_timer.stop()
|
||||||
|
log.info("Brain stopped")
|
||||||
|
|
||||||
|
def update_from_event(self, event: str, data: dict):
|
||||||
|
"""Update brain state from bridge events."""
|
||||||
|
if event == "health_changed":
|
||||||
|
self._health = data.get("health", self._health)
|
||||||
|
self._food = data.get("food", self._food)
|
||||||
|
elif event == "time_update":
|
||||||
|
self._day_time = data.get("dayTime", self._day_time)
|
||||||
|
elif event == "spawn_complete":
|
||||||
|
pos = data.get("position", {})
|
||||||
|
self._position = {"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)}
|
||||||
|
if not self._has_spawn:
|
||||||
|
self._spawn_pos = dict(self._position)
|
||||||
|
self._has_spawn = True
|
||||||
|
elif event == "damage_taken":
|
||||||
|
log.info(f"Doug took damage! Health: {self._health}")
|
||||||
|
elif event == "movement_complete":
|
||||||
|
self._is_moving = False
|
||||||
|
self._current_action = "idle"
|
||||||
|
self._idle_since = time.time()
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
"""One brain tick — observe, decide, act."""
|
||||||
|
from PySide6.QtNetwork import QAbstractSocket
|
||||||
|
if not self._running or self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ticks_since_chat += 1
|
||||||
|
|
||||||
|
# If movement is taking too long (> 15 sec), cancel it
|
||||||
|
if self._is_moving and (time.time() - self._action_start_time > 15):
|
||||||
|
self._is_moving = False
|
||||||
|
self._current_action = "idle"
|
||||||
|
self._idle_since = time.time()
|
||||||
|
log.debug("Movement timed out, going idle")
|
||||||
|
|
||||||
|
# Request current status from bridge
|
||||||
|
if not self._pending_status:
|
||||||
|
self._pending_status = True
|
||||||
|
self._ws.send_request("status", {}, self._on_status)
|
||||||
|
self._ws.send_request("get_nearby_entities", {"radius": 16}, self._on_entities)
|
||||||
|
|
||||||
|
def _on_status(self, response: ResponseMessage):
|
||||||
|
"""Process status response from bridge."""
|
||||||
|
self._pending_status = False
|
||||||
|
if response.status != "success":
|
||||||
|
return
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
self._position = data.get("position", self._position)
|
||||||
|
self._health = data.get("health", self._health)
|
||||||
|
self._food = data.get("food", self._food)
|
||||||
|
self._day_time = data.get("dayTime", self._day_time)
|
||||||
|
|
||||||
|
# Now make a decision
|
||||||
|
self._decide()
|
||||||
|
|
||||||
|
def _on_entities(self, response: ResponseMessage):
|
||||||
|
"""Process nearby entities response."""
|
||||||
|
if response.status != "success":
|
||||||
|
return
|
||||||
|
|
||||||
|
entities = response.data.get("entities", [])
|
||||||
|
self._nearby_players = [
|
||||||
|
e for e in entities
|
||||||
|
if e.get("type") == "player" and e.get("name") != self._doug_name
|
||||||
|
]
|
||||||
|
self._nearby_hostiles = [
|
||||||
|
e for e in entities if e.get("isHostile", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _decide(self):
|
||||||
|
"""Core decision logic — what should Doug do right now?"""
|
||||||
|
|
||||||
|
# Continue walking if we have steps left
|
||||||
|
if self._is_moving and self._current_action == "wandering":
|
||||||
|
if hasattr(self, '_walk_steps_left') and self._walk_steps_left > 0:
|
||||||
|
self._walk_steps_left -= 1
|
||||||
|
target = getattr(self, '_walk_target', None)
|
||||||
|
if target:
|
||||||
|
dx = target["x"] - self._position["x"]
|
||||||
|
dz = target["z"] - self._position["z"]
|
||||||
|
dist = math.sqrt(dx * dx + dz * dz)
|
||||||
|
if dist > 0.5:
|
||||||
|
nx = dx / dist
|
||||||
|
nz = dz / dist
|
||||||
|
step = min(1.5, dist)
|
||||||
|
self._ws.send_request("move_relative", {
|
||||||
|
"dx": nx * step,
|
||||||
|
"dy": 0,
|
||||||
|
"dz": nz * step,
|
||||||
|
})
|
||||||
|
self._position["x"] += nx * step
|
||||||
|
self._position["z"] += nz * step
|
||||||
|
return
|
||||||
|
# Arrived
|
||||||
|
self._is_moving = False
|
||||||
|
self._current_action = "idle"
|
||||||
|
self._idle_since = time.time()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Don't interrupt other actions
|
||||||
|
if self._is_moving:
|
||||||
|
return
|
||||||
|
|
||||||
|
idle_duration = time.time() - self._idle_since
|
||||||
|
|
||||||
|
# Priority 1: Flee from CLOSE hostiles (within 8 blocks) when hurt
|
||||||
|
close_hostiles = [h for h in self._nearby_hostiles if self._distance_to(h) < 8]
|
||||||
|
if close_hostiles and self._health < 14:
|
||||||
|
self._flee_from_hostile(close_hostiles[0])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 2: Wander every 4-8 seconds of idle
|
||||||
|
if idle_duration > random.uniform(4, 8):
|
||||||
|
self._wander()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Priority 3: Look around when idle
|
||||||
|
if idle_duration > 2 and random.random() < 0.4:
|
||||||
|
self._look_around()
|
||||||
|
return
|
||||||
|
|
||||||
|
def _distance_to(self, entity: dict) -> float:
|
||||||
|
"""Distance from Doug to an entity."""
|
||||||
|
epos = entity.get("position", {})
|
||||||
|
dx = self._position["x"] - epos.get("x", 0)
|
||||||
|
dz = self._position["z"] - epos.get("z", 0)
|
||||||
|
return math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
|
def _wander(self):
|
||||||
|
"""Walk to a random nearby position using small teleport steps."""
|
||||||
|
# Pick a random direction and distance (3-8 blocks)
|
||||||
|
angle = random.uniform(0, 2 * math.pi)
|
||||||
|
dist = random.uniform(3, 8)
|
||||||
|
target_x = self._position["x"] + math.cos(angle) * dist
|
||||||
|
target_z = self._position["z"] + math.sin(angle) * dist
|
||||||
|
|
||||||
|
# Don't wander too far from spawn (50 block radius)
|
||||||
|
if self._has_spawn:
|
||||||
|
dx = target_x - self._spawn_pos["x"]
|
||||||
|
dz = target_z - self._spawn_pos["z"]
|
||||||
|
if math.sqrt(dx * dx + dz * dz) > 50:
|
||||||
|
angle = math.atan2(
|
||||||
|
self._spawn_pos["z"] - self._position["z"],
|
||||||
|
self._spawn_pos["x"] - self._position["x"],
|
||||||
|
)
|
||||||
|
target_x = self._position["x"] + math.cos(angle) * 6
|
||||||
|
target_z = self._position["z"] + math.sin(angle) * 6
|
||||||
|
log.debug("Wandering back toward spawn")
|
||||||
|
|
||||||
|
# Use small relative teleports (~1 block per step) to simulate walking
|
||||||
|
dx = target_x - self._position["x"]
|
||||||
|
dz = target_z - self._position["z"]
|
||||||
|
total_dist = math.sqrt(dx * dx + dz * dz)
|
||||||
|
if total_dist < 0.5:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normalize direction
|
||||||
|
nx = dx / total_dist
|
||||||
|
nz = dz / total_dist
|
||||||
|
|
||||||
|
# Take a single step of ~1.5 blocks toward target
|
||||||
|
step = min(1.5, total_dist)
|
||||||
|
self._ws.send_request("move_relative", {
|
||||||
|
"dx": nx * step,
|
||||||
|
"dy": 0,
|
||||||
|
"dz": nz * step,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update local position estimate
|
||||||
|
self._position["x"] += nx * step
|
||||||
|
self._position["z"] += nz * step
|
||||||
|
|
||||||
|
# Keep walking for a few ticks (set timer to continue)
|
||||||
|
self._walk_target = {"x": target_x, "z": target_z}
|
||||||
|
self._walk_steps_left = int(total_dist / 1.5)
|
||||||
|
self._is_moving = True
|
||||||
|
self._action_start_time = time.time()
|
||||||
|
self._current_action = "wandering"
|
||||||
|
log.debug(f"Walking toward ({target_x:.0f}, {self._position['y']:.0f}, {target_z:.0f}) — {total_dist:.0f} blocks")
|
||||||
|
|
||||||
|
def _look_around(self):
|
||||||
|
"""Look at a random direction."""
|
||||||
|
look_x = self._position["x"] + random.uniform(-20, 20)
|
||||||
|
look_y = self._position["y"] + random.uniform(-3, 5)
|
||||||
|
look_z = self._position["z"] + random.uniform(-20, 20)
|
||||||
|
|
||||||
|
self._ws.send_request("look_at", {
|
||||||
|
"x": look_x,
|
||||||
|
"y": look_y,
|
||||||
|
"z": look_z,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _flee_from_hostile(self, hostile: dict):
|
||||||
|
"""Run away from a hostile mob using /tp."""
|
||||||
|
hpos = hostile.get("position", {})
|
||||||
|
dx = self._position["x"] - hpos.get("x", 0)
|
||||||
|
dz = self._position["z"] - hpos.get("z", 0)
|
||||||
|
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
||||||
|
|
||||||
|
# Sprint 3 blocks away in opposite direction
|
||||||
|
flee_dx = (dx / dist) * 3
|
||||||
|
flee_dz = (dz / dist) * 3
|
||||||
|
|
||||||
|
self._ws.send_request("move_relative", {
|
||||||
|
"dx": flee_dx,
|
||||||
|
"dy": 0,
|
||||||
|
"dz": flee_dz,
|
||||||
|
})
|
||||||
|
|
||||||
|
self._position["x"] += flee_dx
|
||||||
|
self._position["z"] += flee_dz
|
||||||
|
self._is_moving = True
|
||||||
|
self._action_start_time = time.time()
|
||||||
|
self._current_action = "fleeing"
|
||||||
|
log.info(f"Fleeing from {hostile.get('type', 'mob')}!")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_action(self) -> str:
|
||||||
|
return self._current_action
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_night(self) -> bool:
|
||||||
|
return self._day_time > 12000
|
||||||
|
|
@ -15,6 +15,7 @@ from dougbot.db.queries import DougRepository, ChatRepository
|
||||||
from dougbot.db.models import DougModel, PersonaConfig
|
from dougbot.db.models import DougModel, PersonaConfig
|
||||||
from dougbot.bridge.node_manager import NodeManager
|
from dougbot.bridge.node_manager import NodeManager
|
||||||
from dougbot.bridge.ws_client import BridgeWSClient
|
from dougbot.bridge.ws_client import BridgeWSClient
|
||||||
|
from dougbot.core.brain import DougBrain
|
||||||
from dougbot.ai.ollama_client import OllamaClient
|
from dougbot.ai.ollama_client import OllamaClient
|
||||||
from dougbot.ai.prompt_builder import build_system_prompt
|
from dougbot.ai.prompt_builder import build_system_prompt
|
||||||
from dougbot.utils.logging import get_logger
|
from dougbot.utils.logging import get_logger
|
||||||
|
|
@ -42,6 +43,7 @@ class MainWindow(QMainWindow):
|
||||||
self._active_doug: DougModel | None = None
|
self._active_doug: DougModel | None = None
|
||||||
self._node_manager: NodeManager | None = None
|
self._node_manager: NodeManager | None = None
|
||||||
self._ws_client: BridgeWSClient | None = None
|
self._ws_client: BridgeWSClient | None = None
|
||||||
|
self._brain: DougBrain | None = None
|
||||||
self._ollama: OllamaClient | None = None
|
self._ollama: OllamaClient | None = None
|
||||||
self._chat_repo = ChatRepository(self.db)
|
self._chat_repo = ChatRepository(self.db)
|
||||||
self._ws_port_counter = self.config.get("bridge_base_port", 8765)
|
self._ws_port_counter = self.config.get("bridge_base_port", 8765)
|
||||||
|
|
@ -218,6 +220,10 @@ class MainWindow(QMainWindow):
|
||||||
self._ws_client.disconnect_from_bridge()
|
self._ws_client.disconnect_from_bridge()
|
||||||
self._ws_client = None
|
self._ws_client = None
|
||||||
|
|
||||||
|
if self._brain:
|
||||||
|
self._brain.stop()
|
||||||
|
self._brain = None
|
||||||
|
|
||||||
if self._node_manager:
|
if self._node_manager:
|
||||||
self._node_manager.stop()
|
self._node_manager.stop()
|
||||||
self._node_manager = None
|
self._node_manager = None
|
||||||
|
|
@ -350,6 +356,12 @@ class MainWindow(QMainWindow):
|
||||||
self.dashboard.log_viewer.append_system(
|
self.dashboard.log_viewer.append_system(
|
||||||
f"Spawned at ({pos.get('x', 0):.0f}, {pos.get('y', 0):.0f}, {pos.get('z', 0):.0f})"
|
f"Spawned at ({pos.get('x', 0):.0f}, {pos.get('y', 0):.0f}, {pos.get('z', 0):.0f})"
|
||||||
)
|
)
|
||||||
|
# Start the brain!
|
||||||
|
if self._ws_client and self._active_doug:
|
||||||
|
self._brain = DougBrain(self._ws_client, self._active_doug.name, parent=self)
|
||||||
|
self._brain.update_from_event("spawn_complete", data)
|
||||||
|
self._brain.start()
|
||||||
|
self.dashboard.log_viewer.append_system("Brain activated — Doug is now autonomous!")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif event == "chat_message":
|
elif event == "chat_message":
|
||||||
|
|
@ -383,6 +395,10 @@ class MainWindow(QMainWindow):
|
||||||
reason = data.get("reason", "unknown")
|
reason = data.get("reason", "unknown")
|
||||||
self.dashboard.log_viewer.append_error(f"Disconnected: {reason}")
|
self.dashboard.log_viewer.append_error(f"Disconnected: {reason}")
|
||||||
|
|
||||||
|
# Forward all events to the brain
|
||||||
|
if self._brain:
|
||||||
|
self._brain.update_from_event(event, data)
|
||||||
|
|
||||||
# ── Chat AI ──
|
# ── Chat AI ──
|
||||||
|
|
||||||
def _should_respond(self, message: str) -> bool:
|
def _should_respond(self, message: str) -> bool:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue