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', () => {
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
|
||||
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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ export type EventType =
|
|||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'error'
|
||||
| 'auth_device_code';
|
||||
| 'auth_device_code'
|
||||
| 'movement_complete';
|
||||
|
||||
// ============================================================
|
||||
// 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:
|
||||
"""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")
|
||||
|
|
|
|||
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.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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue