diff --git a/bridge/src/actions/movement.ts b/bridge/src/actions/movement.ts new file mode 100644 index 0000000..3ce6144 --- /dev/null +++ b/bridge/src/actions/movement.ts @@ -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 = { + 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 | 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 = {}): 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; + } +} diff --git a/bridge/src/client.ts b/bridge/src/client.ts index e699bfb..de33770 100644 --- a/bridge/src/client.ts +++ b/bridge/src/client.ts @@ -93,6 +93,20 @@ export class BedrockClient extends EventEmitter { 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 }); }); @@ -234,10 +248,13 @@ export class BedrockClient extends EventEmitter { // 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: packet.player_position.x, - y: packet.player_position.y, - z: packet.player_position.z, + x: Number(packet.player_position.x), + y: y, + z: Number(packet.player_position.z), }; 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) }); } + /** + * 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. */ diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 22410f7..48442d2 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -13,6 +13,8 @@ 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'; @@ -130,6 +132,19 @@ async function main(): Promise { 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 @@ -189,6 +204,59 @@ async function main(): Promise { 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; diff --git a/bridge/src/protocol.ts b/bridge/src/protocol.ts index b4d799b..a52dcb3 100644 --- a/bridge/src/protocol.ts +++ b/bridge/src/protocol.ts @@ -95,7 +95,8 @@ export type EventType = | 'connected' | 'disconnected' | 'error' - | 'auth_device_code'; + | 'auth_device_code' + | 'movement_complete'; // ============================================================ // Data Structures diff --git a/bridge/src/world/entity_tracker.ts b/bridge/src/world/entity_tracker.ts new file mode 100644 index 0000000..aea7e7c --- /dev/null +++ b/bridge/src/world/entity_tracker.ts @@ -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 = new Map(); + private players: Map = 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(); + } +} diff --git a/dougbot/bridge/ws_client.py b/dougbot/bridge/ws_client.py index 292ab0c..4b270d2 100644 --- a/dougbot/bridge/ws_client.py +++ b/dougbot/bridge/ws_client.py @@ -93,7 +93,8 @@ class BridgeWSClient(QObject): def is_connected(self) -> bool: """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: log.info("Connected to bridge WebSocket") diff --git a/dougbot/core/brain.py b/dougbot/core/brain.py new file mode 100644 index 0000000..c7369e0 --- /dev/null +++ b/dougbot/core/brain.py @@ -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 diff --git a/dougbot/gui/main_window.py b/dougbot/gui/main_window.py index 7f71ab2..d09af69 100644 --- a/dougbot/gui/main_window.py +++ b/dougbot/gui/main_window.py @@ -15,6 +15,7 @@ from dougbot.db.queries import DougRepository, ChatRepository from dougbot.db.models import DougModel, PersonaConfig from dougbot.bridge.node_manager import NodeManager from dougbot.bridge.ws_client import BridgeWSClient +from dougbot.core.brain import DougBrain from dougbot.ai.ollama_client import OllamaClient from dougbot.ai.prompt_builder import build_system_prompt from dougbot.utils.logging import get_logger @@ -42,6 +43,7 @@ class MainWindow(QMainWindow): self._active_doug: DougModel | None = None self._node_manager: NodeManager | None = None self._ws_client: BridgeWSClient | None = None + self._brain: DougBrain | None = None self._ollama: OllamaClient | None = None self._chat_repo = ChatRepository(self.db) 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 = None + if self._brain: + self._brain.stop() + self._brain = None + if self._node_manager: self._node_manager.stop() self._node_manager = None @@ -350,6 +356,12 @@ class MainWindow(QMainWindow): self.dashboard.log_viewer.append_system( 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 elif event == "chat_message": @@ -383,6 +395,10 @@ class MainWindow(QMainWindow): reason = data.get("reason", "unknown") 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 ── def _should_respond(self, message: str) -> bool: