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:
roberts 2026-03-30 10:30:39 -05:00
parent 13f5c84069
commit 9aa0abbf59
8 changed files with 721 additions and 5 deletions

View 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;
}
}

View file

@ -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.
*/ */

View file

@ -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;

View file

@ -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

View 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();
}
}

View file

@ -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
View 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

View file

@ -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: