Phase 3: Task queue, behavior engine, trait-driven decisions

- TaskQueue with 6 priority levels (IDLE → CRITICAL)
- BehaviorEngine generates tasks based on persona traits:
  - Survival: flee (bravery-weighted), eat, seek shelter (anxiety)
  - Combat: attack hostiles (bravery threshold)
  - Social: follow players (sociability), approach for interaction
  - Exploration: read signs, check containers, wander (curiosity range)
  - Organization: inventory management (OCD quirk)
  - Idle: look around, unprompted chat (chatty_cathy)
- Brain rewritten to use scan → generate → execute loop
- New bridge actions: open_chest, close_container, transfer_item,
  scan_surroundings, find_blocks, attack_nearest_hostile,
  list_recipes, craft_item, use_block, drop_item
- Traits influence: flee distance, wander range, combat willingness,
  social approach frequency, container curiosity
- Brain passes persona traits from database to behavior engine
- Unprompted AI chat via wants_ai_chat signal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 12:48:15 -05:00
parent 8f616598fd
commit b609d4c896
6 changed files with 935 additions and 171 deletions

View file

@ -27,7 +27,10 @@
"Bash(node -e \"const o = require\\(''bedrock-protocol/src/options''\\); console.log\\(''CURRENT_VERSION:'', o.CURRENT_VERSION\\); const keys = Object.keys\\(o.Versions\\); console.log\\(''First 5:'', keys.slice\\(0,5\\)\\); console.log\\(''Last 5:'', keys.slice\\(-5\\)\\)\")",
"Bash(node dist/index.js --host 192.168.1.90 --port 19140 --username Doug-Offline --offline --ws-port 9999)",
"Bash(echo \"Exit: $?\")",
"Bash(npm ls:*)"
"Bash(npm ls:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
]
}
}

View file

@ -416,6 +416,227 @@ async function handleAction(action, params = {}) {
};
}
// --- Chest / Container Interaction ---
case 'open_chest': {
const { x, y, z } = params;
const chestBlock = bot.blockAt(new Vec3(x, y, z));
if (!chestBlock) throw new Error(`No block at ${x},${y},${z}`);
const chest = await bot.openContainer(chestBlock);
const items = chest.containerItems().map(item => ({
name: item.name,
count: item.count,
slot: item.slot,
displayName: item.displayName,
}));
// Store reference for subsequent operations
bot._openContainer = chest;
return { items, slots: chest.containerItems().length };
}
case 'close_container': {
if (bot._openContainer) {
bot._openContainer.close();
bot._openContainer = null;
}
return { closed: true };
}
case 'transfer_item': {
// Move items between containers/inventory
const { itemName, count, toContainer } = params;
if (!bot._openContainer) throw new Error('No container open');
const container = bot._openContainer;
if (toContainer) {
// From inventory to container: deposit
const item = bot.inventory.items().find(i => i.name === itemName);
if (!item) throw new Error(`Item ${itemName} not in inventory`);
await container.deposit(item.type, item.metadata, count || item.count);
} else {
// From container to inventory: withdraw
const item = container.containerItems().find(i => i.name === itemName);
if (!item) throw new Error(`Item ${itemName} not in container`);
await container.withdraw(item.type, item.metadata, count || item.count);
}
return { transferred: itemName, count: count || 1 };
}
// --- Surroundings Scan ---
case 'scan_surroundings': {
const radius = params.radius || 8;
const pos = bot.entity.position;
const result = {
position: { x: pos.x, y: pos.y, z: pos.z },
blocks: {}, // Notable blocks nearby
entities: [], // Nearby entities
players: [], // Nearby players
signs: [], // Signs with text
containers: [],// Chests, barrels, etc.
time: bot.time?.timeOfDay || 0,
health: bot.health,
food: bot.food,
isRaining: bot.isRaining || false,
};
// Scan blocks in radius
const containerTypes = new Set(['chest', 'trapped_chest', 'barrel', 'shulker_box', 'ender_chest']);
const signTypes = new Set(['oak_sign', 'spruce_sign', 'birch_sign', 'jungle_sign', 'acacia_sign',
'dark_oak_sign', 'mangrove_sign', 'cherry_sign', 'bamboo_sign', 'crimson_sign', 'warped_sign',
'oak_wall_sign', 'spruce_wall_sign', 'birch_wall_sign', 'jungle_wall_sign', 'acacia_wall_sign',
'dark_oak_wall_sign', 'mangrove_wall_sign', 'cherry_wall_sign', 'bamboo_wall_sign',
'crimson_wall_sign', 'warped_wall_sign', 'standing_sign', 'wall_sign']);
const interestingBlocks = new Set(['crafting_table', 'furnace', 'blast_furnace', 'smoker',
'anvil', 'enchanting_table', 'brewing_stand', 'bed', 'door', 'campfire', 'soul_campfire',
'torch', 'lantern']);
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -4; dy <= 4; dy++) {
for (let dz = -radius; dz <= radius; dz++) {
if (dx * dx + dz * dz > radius * radius) continue;
const blockPos = pos.offset(dx, dy, dz);
const block = bot.blockAt(blockPos);
if (!block || block.name === 'air') continue;
const bName = block.name.replace('minecraft:', '');
if (containerTypes.has(bName)) {
result.containers.push({
type: bName,
position: { x: blockPos.x, y: blockPos.y, z: blockPos.z },
});
}
if (signTypes.has(bName)) {
// Try to read sign text
let text = '';
try {
const signEntity = block.blockEntity || block.entity;
if (signEntity && signEntity.Text) text = signEntity.Text;
else if (block.signText) text = block.signText;
} catch (e) {}
result.signs.push({
position: { x: blockPos.x, y: blockPos.y, z: blockPos.z },
text: text || '(unreadable)',
});
}
if (interestingBlocks.has(bName)) {
if (!result.blocks[bName]) result.blocks[bName] = [];
result.blocks[bName].push({ x: blockPos.x, y: blockPos.y, z: blockPos.z });
}
}
}
}
// Entities and players
for (const entity of Object.values(bot.entities)) {
if (entity === bot.entity) continue;
if (!entity.position) continue;
const dist = entity.position.distanceTo(pos);
if (dist > radius) continue;
const info = {
id: entity.id,
type: entity.name || entity.type || 'unknown',
name: entity.username || entity.nametag || entity.name || 'unknown',
position: { x: entity.position.x, y: entity.position.y, z: entity.position.z },
distance: dist,
isHostile: isHostile(entity),
};
if (entity.type === 'player') {
result.players.push(info);
} else {
result.entities.push(info);
}
}
return result;
}
// --- Find Blocks ---
case 'find_blocks': {
const { blockName, radius: searchRadius, count: maxCount } = params;
const r = searchRadius || 32;
const max = maxCount || 10;
const blocks = bot.findBlocks({
matching: (block) => {
const name = block.name.replace('minecraft:', '');
return name === blockName || name.includes(blockName);
},
maxDistance: r,
count: max,
});
return {
blocks: blocks.map(pos => ({
position: { x: pos.x, y: pos.y, z: pos.z },
name: bot.blockAt(pos)?.name || blockName,
})),
};
}
// --- Combat ---
case 'attack_nearest_hostile': {
const range = params.range || 5;
const hostiles = [];
for (const entity of Object.values(bot.entities)) {
if (entity === bot.entity || !entity.position) continue;
if (!isHostile(entity)) continue;
const dist = entity.position.distanceTo(bot.entity.position);
if (dist <= range) hostiles.push({ entity, dist });
}
if (hostiles.length === 0) return { attacked: false, reason: 'no_hostiles' };
hostiles.sort((a, b) => a.dist - b.dist);
const target = hostiles[0].entity;
bot.attack(target);
return { attacked: true, target: target.name || target.type, distance: hostiles[0].dist };
}
// --- Crafting ---
case 'list_recipes': {
const { itemName } = params;
const mcData = require('minecraft-data')(bot.version);
const item = mcData.itemsByName[itemName];
if (!item) return { recipes: [], error: `Unknown item: ${itemName}` };
const recipes = bot.recipesFor(item.id);
return {
recipes: recipes.map((r, i) => ({
index: i,
ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [],
})),
};
}
case 'craft_item': {
const { itemName, count: craftCount } = params;
const mcData = require('minecraft-data')(bot.version);
const item = mcData.itemsByName[itemName];
if (!item) throw new Error(`Unknown item: ${itemName}`);
// Find crafting table nearby if needed
const craftingTable = bot.findBlock({
matching: (block) => block.name.includes('crafting_table'),
maxDistance: 4,
});
const recipes = bot.recipesFor(item.id, null, null, craftingTable || undefined);
if (recipes.length === 0) throw new Error(`No recipe found for ${itemName}`);
await bot.craft(recipes[0], craftCount || 1, craftingTable || undefined);
return { crafted: itemName, count: craftCount || 1 };
}
// --- Use/Activate Block ---
case 'use_block': {
const { x, y, z } = params;
const block = bot.blockAt(new Vec3(x, y, z));
if (!block) throw new Error(`No block at ${x},${y},${z}`);
await bot.activateBlock(block);
return { used: block.name };
}
// --- Drop/Toss Items ---
case 'drop_item': {
const { itemName, count: dropCount } = params;
const item = bot.inventory.items().find(i => i.name === itemName);
if (!item) throw new Error(`Item ${itemName} not in inventory`);
await bot.toss(item.type, item.metadata, dropCount || 1);
return { dropped: itemName, count: dropCount || 1 };
}
default:
throw new Error(`Unknown action: ${action}`);
}

379
dougbot/core/behaviors.py Normal file
View file

@ -0,0 +1,379 @@
"""
Behavior modules for Doug. Each behavior generates tasks based on
world state and persona traits.
"""
import math
import random
import time
from typing import Optional
from dougbot.core.task_queue import Task, Priority
from dougbot.utils.logging import get_logger
log = get_logger("core.behaviors")
class BehaviorEngine:
"""Generates tasks based on Doug's state, surroundings, and personality."""
def __init__(self, traits: dict, age: int, doug_name: str):
self._traits = traits # Persona trait values
self._age = age
self._name = doug_name
# World state (updated by brain)
self.position = {"x": 0, "y": 0, "z": 0}
self.health = 20
self.food = 20
self.day_time = 0
self.is_raining = False
self.nearby_players: list[dict] = []
self.nearby_entities: list[dict] = []
self.nearby_hostiles: list[dict] = []
self.nearby_containers: list[dict] = []
self.nearby_signs: list[dict] = []
self.nearby_blocks: dict = {}
self.inventory: list[dict] = []
self.spawn_pos = {"x": 0, "y": 0, "z": 0}
# Behavior state
self._last_scan_time = 0.0
self._last_chat_time = 0.0
self._last_wander_time = 0.0
self._explored_positions: list[dict] = [] # Places we've been
self._known_containers: list[dict] = [] # Containers we've found
self._relationships: dict[str, float] = {} # Player name → fondness (-1 to 1)
self._deaths_seen: list[dict] = []
# --- Trait helpers ---
def _trait(self, name: str, default: int = 50) -> int:
"""Get a trait value (0-100 slider) or bool quirk."""
return self._traits.get(name, default)
def _has_quirk(self, name: str) -> bool:
"""Check if a boolean quirk is enabled."""
return bool(self._traits.get(name, False))
def _trait_chance(self, trait_name: str, base: float = 0.5) -> bool:
"""Random check weighted by a trait. Higher trait = more likely."""
val = self._trait(trait_name, 50) / 100.0
return random.random() < (base * val)
# --- Core behavior generators ---
def get_survival_task(self) -> Optional[Task]:
"""Check for survival needs — health, food, immediate danger."""
# Critical health — flee from everything
if self.health <= 4:
hostile = self._nearest_hostile(12)
if hostile:
return self._flee_task(hostile, "Critical health!")
# Flee from close hostiles based on bravery
bravery = self._trait("bravery", 50)
flee_distance = max(4, 12 - bravery // 10) # Brave = smaller flee radius
flee_health_threshold = max(6, 18 - bravery // 8) # Brave = lower threshold
close_hostile = self._nearest_hostile(flee_distance)
if close_hostile and self.health < flee_health_threshold:
return self._flee_task(close_hostile, f"Fleeing from {close_hostile.get('type', 'mob')}")
# Anxiety quirk: flee from ANY hostile within 10 blocks regardless of health
if self._has_quirk("anxiety") and self._nearest_hostile(10):
hostile = self._nearest_hostile(10)
return self._flee_task(hostile, "Too scary!")
# Night fear (anxiety): seek shelter
if self._has_quirk("anxiety") and self.is_night and not self._is_near_shelter():
return Task(
name="seek_shelter",
priority=Priority.URGENT,
action="move_to",
params={**self.spawn_pos, "range": 3},
description="Running back to safety",
timeout=30,
)
# Eat if hungry and we have food
if self.food <= 8:
food_item = self._find_food_in_inventory()
if food_item:
return Task(
name="eat",
priority=Priority.URGENT,
action="equip_item",
params={"name": food_item, "destination": "hand"},
description=f"Eating {food_item}",
timeout=10,
)
return None
def get_social_task(self) -> Optional[Task]:
"""Social behaviors — interact with nearby players."""
if not self.nearby_players:
return None
sociability = self._trait("sociability", 50)
# Follow nearby player if sociable and they're far-ish
for player in self.nearby_players:
dist = player.get("distance", 99)
# Very social Doug follows players around
if sociability > 70 and dist > 6 and dist < 30:
if self._trait_chance("sociability", 0.3):
return Task(
name=f"follow_{player['name']}",
priority=Priority.LOW,
action="follow_player",
params={"name": player["name"], "range": 4},
description=f"Following {player['name']}",
timeout=20,
)
# Walk toward player if they're close enough to interact
if dist > 3 and dist < 15 and self._trait_chance("sociability", 0.15):
return Task(
name=f"approach_{player['name']}",
priority=Priority.LOW,
action="move_to",
params={**player["position"], "range": 3},
description=f"Walking toward {player['name']}",
timeout=15,
)
return None
def get_exploration_task(self) -> Optional[Task]:
"""Exploration and curiosity behaviors."""
curiosity = self._trait("curiosity", 50)
# Check signs nearby
if self.nearby_signs and self._trait_chance("curiosity", 0.5):
sign = self.nearby_signs[0]
sign_pos = sign["position"]
dist = self._distance_to_pos(sign_pos)
if dist > 2:
return Task(
name="read_sign",
priority=Priority.NORMAL,
action="move_to",
params={**sign_pos, "range": 2},
description=f"Going to read a sign",
timeout=15,
)
# Check containers nearby (OCD quirk = organize, curiosity = peek)
if self.nearby_containers:
for container in self.nearby_containers:
dist = self._distance_to_pos(container["position"])
if dist < 5:
if self._has_quirk("ocd") or self._trait_chance("curiosity", 0.2):
return Task(
name="check_container",
priority=Priority.NORMAL,
action="open_chest",
params=container["position"],
description=f"Checking a {container['type']}",
timeout=15,
callback="on_container_opened",
)
# Interesting blocks nearby
if self.nearby_blocks and curiosity > 40:
for block_type, positions in self.nearby_blocks.items():
if block_type == "crafting_table" and self._trait_chance("curiosity", 0.1):
pos = positions[0]
return Task(
name="visit_crafting_table",
priority=Priority.LOW,
action="move_to",
params={**pos, "range": 2},
description="Checking out a crafting table",
timeout=15,
)
# Wander/explore — higher curiosity = farther, more frequent
time_since_wander = time.time() - self._last_wander_time
wander_interval = max(4, 15 - curiosity // 8) # Curious = shorter interval
if time_since_wander > wander_interval:
self._last_wander_time = time.time()
return self._wander_task(curiosity)
return None
def get_combat_task(self) -> Optional[Task]:
"""Combat behaviors — attack hostiles based on bravery."""
bravery = self._trait("bravery", 50)
# Only attack if brave enough
if bravery < 30:
return None # Too scared to fight
# Find attackable hostile within melee range
for hostile in self.nearby_hostiles:
dist = hostile.get("distance", 99)
if dist < 4 and self.health > 8:
# Brave Dougs attack, others might not
if bravery > 60 or (bravery > 40 and self.health > 14):
return Task(
name=f"attack_{hostile['type']}",
priority=Priority.HIGH,
action="attack_nearest_hostile",
params={"range": 5},
description=f"Fighting a {hostile['type']}!",
timeout=10,
)
return None
def get_organization_task(self) -> Optional[Task]:
"""OCD/organization behaviors."""
if not self._has_quirk("ocd"):
return None
# If we have a messy inventory, organize it
if len(self.inventory) > 10 and random.random() < 0.05:
return Task(
name="organize_inventory",
priority=Priority.LOW,
action="status", # Placeholder — will be multi-step
description="Organizing my stuff",
timeout=20,
)
return None
def get_idle_task(self) -> Optional[Task]:
"""Idle behaviors — what Doug does when bored."""
# Look around randomly
if random.random() < 0.4:
return Task(
name="look_around",
priority=Priority.IDLE,
action="look_at",
params={
"x": self.position["x"] + random.uniform(-20, 20),
"y": self.position["y"] + random.uniform(-3, 5),
"z": self.position["z"] + random.uniform(-20, 20),
},
description="",
timeout=3,
)
# Chatty Cathy: say something unprompted
if self._has_quirk("chatty_cathy") and self.nearby_players:
time_since_chat = time.time() - self._last_chat_time
if time_since_chat > 30 and random.random() < 0.15:
self._last_chat_time = time.time()
return Task(
name="idle_chat",
priority=Priority.LOW,
action="status", # Brain will handle via AI
description="chatting",
timeout=10,
callback="on_idle_chat",
)
return None
# --- Task factories ---
def _flee_task(self, hostile: dict, reason: str) -> Task:
"""Create a flee task away from a hostile."""
hpos = hostile.get("position", self.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))
flee_dist = 12
flee_x = self.position["x"] + (dx / dist) * flee_dist
flee_z = self.position["z"] + (dz / dist) * flee_dist
return Task(
name="flee",
priority=Priority.URGENT,
action="move_to",
params={"x": flee_x, "y": self.position["y"], "z": flee_z, "range": 3},
description=reason,
timeout=15,
interruptible=False,
)
def _wander_task(self, curiosity: int) -> Task:
"""Create a wander task with distance based on curiosity."""
angle = random.uniform(0, 2 * math.pi)
dist = random.uniform(5, 8 + curiosity // 10) # Curious = farther
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 (radius based on curiosity)
max_radius = 30 + curiosity // 2 # Curious = wider range
dx = target_x - self.spawn_pos["x"]
dz = target_z - self.spawn_pos["z"]
if math.sqrt(dx * dx + dz * dz) > max_radius:
# Head back toward spawn
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) * 8
target_z = self.position["z"] + math.sin(angle) * 8
return Task(
name="wander",
priority=Priority.IDLE,
action="move_to",
params={"x": target_x, "y": self.position["y"], "z": target_z, "range": 2},
description="",
timeout=20,
)
# --- Helpers ---
def _nearest_hostile(self, max_dist: float) -> Optional[dict]:
"""Get nearest hostile within max_dist."""
closest = None
closest_dist = max_dist
for h in self.nearby_hostiles:
d = h.get("distance", 99)
if d < closest_dist:
closest = h
closest_dist = d
return closest
def _distance_to_pos(self, pos: dict) -> float:
dx = self.position["x"] - pos.get("x", 0)
dy = self.position["y"] - pos.get("y", 0)
dz = self.position["z"] - pos.get("z", 0)
return math.sqrt(dx * dx + dy * dy + dz * dz)
def _find_food_in_inventory(self) -> Optional[str]:
"""Find a food item in inventory."""
food_items = {
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
"golden_apple", "melon_slice", "sweet_berries", "baked_potato",
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie", "pumpkin_pie",
"cake", "dried_kelp", "carrot", "potato",
}
for item in self.inventory:
if item.get("name", "").replace("minecraft:", "") in food_items:
return item["name"]
return None
def _is_near_shelter(self) -> bool:
"""Check if Doug is near a sheltered area (has blocks above)."""
# Simplified: near spawn = near shelter
d = self._distance_to_pos(self.spawn_pos)
return d < 15
@property
def is_night(self) -> bool:
return self.day_time > 12000

View file

@ -1,7 +1,7 @@
"""
Doug's Brain — the autonomous decision loop.
Runs every 2 seconds and decides what Doug should do next.
Uses mineflayer pathfinder for real movement.
Uses behavior engine + task queue for trait-driven decisions.
Ticks every 2 seconds: scan generate tasks execute top task.
"""
import math
@ -11,18 +11,22 @@ from PySide6.QtCore import QObject, QTimer, Signal
from dougbot.bridge.ws_client import BridgeWSClient
from dougbot.bridge.protocol import ResponseMessage
from dougbot.core.task_queue import TaskQueue, Task, Priority
from dougbot.core.behaviors import BehaviorEngine
from dougbot.utils.logging import get_logger
log = get_logger("core.brain")
class DougBrain(QObject):
"""Autonomous decision engine. Ticks every 2 seconds."""
"""Autonomous decision engine with trait-driven behavior."""
# Signal for chat messages the brain wants to send
wants_to_chat = Signal(str) # message
# Signals
wants_to_chat = Signal(str) # Unprompted chat message
wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say
def __init__(self, ws_client: BridgeWSClient, doug_name: str, parent=None):
def __init__(self, ws_client: BridgeWSClient, doug_name: str,
traits: dict = None, age: int = 30, parent=None):
super().__init__(parent)
self._ws = ws_client
self._doug_name = doug_name
@ -30,223 +34,222 @@ class DougBrain(QObject):
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
# Core systems
self._tasks = TaskQueue()
self._behaviors = BehaviorEngine(traits or {}, age, doug_name)
# State request tracking
self._pending_status = False
# Scan state
self._pending_scan = False
self._last_scan_time = 0.0
self._scan_interval = 3.0 # Seconds between full scans
# Action state
self._waiting_for_action = False
self._action_sent_time = 0.0
def start(self):
"""Start the brain loop."""
self._running = True
self._idle_since = time.time()
self._tick_timer.start(2000) # Every 2 seconds
self._tick_timer.start(2000)
log.info("Brain started — Doug is thinking")
def stop(self):
"""Stop the brain loop."""
self._running = False
self._tick_timer.stop()
# Tell bridge to stop moving
self._tasks.clear()
self._ws.send_request("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":
if 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}")
self._behaviors.position = {
"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)
}
self._behaviors.spawn_pos = dict(self._behaviors.position)
elif event == "health_changed":
self._behaviors.health = data.get("health", 20)
self._behaviors.food = data.get("food", 20)
elif event == "time_update":
self._behaviors.day_time = data.get("dayTime", 0)
elif event == "movement_complete":
self._is_moving = False
self._current_action = "idle"
self._idle_since = time.time()
self._waiting_for_action = False
self._tasks.complete()
elif event == "movement_failed":
self._is_moving = False
self._current_action = "idle"
self._idle_since = time.time()
log.debug("Movement failed (no path)")
self._waiting_for_action = False
self._tasks.cancel()
elif event == "death":
self._waiting_for_action = False
self._tasks.clear()
log.info("Doug died — clearing all tasks")
elif event == "player_joined":
username = data.get("username", "")
if username and username != self._doug_name:
log.info(f"Player joined: {username}")
elif event == "player_left":
username = data.get("username", "")
if username:
log.info(f"Player left: {username}")
def _tick(self):
"""One brain tick — observe, decide, act."""
"""Main brain tick — scan, generate tasks, execute."""
from PySide6.QtNetwork import QAbstractSocket
if not self._running or self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
if not self._running:
return
if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
return
self._ticks_since_chat += 1
# Safety: unstick action timeout
if self._waiting_for_action and (time.time() - self._action_sent_time > 20):
self._waiting_for_action = False
self._tasks.cancel()
# If movement is taking too long (> 30 sec), cancel it
if self._is_moving and (time.time() - self._action_start_time > 30):
self._ws.send_request("stop", {})
self._is_moving = False
self._current_action = "idle"
self._idle_since = time.time()
log.debug("Movement timed out, stopping")
# Safety: unstick pending scan
if self._pending_scan and (time.time() - self._last_scan_time > 10):
self._pending_scan = False
# Request current status from bridge
# Safety: reset pending flag if it's been stuck for more than 10 seconds
if self._pending_status and (time.time() - self._action_start_time > 10):
self._pending_status = False
# Step 1: Scan surroundings periodically
if not self._pending_scan and (time.time() - self._last_scan_time > self._scan_interval):
self._pending_scan = True
self._last_scan_time = time.time()
self._ws.send_request("scan_surroundings", {"radius": 12}, self._on_scan)
self._ws.send_request("get_inventory", {}, self._on_inventory)
return # Wait for scan results before deciding
if not self._pending_status:
self._pending_status = True
self._action_start_time = time.time()
self._ws.send_request("status", {}, self._on_status)
self._ws.send_request("get_nearby_entities", {"radius": 16}, self._on_entities)
# Step 2: Generate tasks from behaviors (if not waiting for scan)
if not self._pending_scan and not self._waiting_for_action:
self._generate_tasks()
def _on_status(self, response: ResponseMessage):
"""Process status response from bridge."""
self._pending_status = False
# Step 3: Execute top task
if not self._waiting_for_action:
self._execute_next_task()
def _on_scan(self, response: ResponseMessage):
"""Process surroundings scan."""
self._pending_scan = 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)
self._behaviors.position = data.get("position", self._behaviors.position)
self._behaviors.health = data.get("health", self._behaviors.health)
self._behaviors.food = data.get("food", self._behaviors.food)
self._behaviors.day_time = data.get("time", self._behaviors.day_time)
self._behaviors.is_raining = data.get("isRaining", False)
self._behaviors.nearby_players = data.get("players", [])
self._behaviors.nearby_containers = data.get("containers", [])
self._behaviors.nearby_signs = data.get("signs", [])
self._behaviors.nearby_blocks = data.get("blocks", {})
# Now make a decision
self._decide()
# Split entities into hostiles and others
entities = data.get("entities", [])
self._behaviors.nearby_entities = entities
self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)]
def _on_entities(self, response: ResponseMessage):
"""Process nearby entities response."""
def _on_inventory(self, response: ResponseMessage):
"""Process inventory response."""
if response.status != "success":
return
self._behaviors.inventory = response.data.get("items", [])
entities = response.data.get("entities", [])
self._nearby_players = [
e for e in entities
if e.get("isPlayer") and e.get("name") != self._doug_name
]
self._nearby_hostiles = [
e for e in entities if e.get("isHostile", False)
def _generate_tasks(self):
"""Ask behavior engine to generate tasks based on current state."""
# Priority order: survival → combat → social → exploration → organization → idle
generators = [
self._behaviors.get_survival_task,
self._behaviors.get_combat_task,
self._behaviors.get_social_task,
self._behaviors.get_exploration_task,
self._behaviors.get_organization_task,
self._behaviors.get_idle_task,
]
def _decide(self):
"""Core decision logic — what should Doug do right now?"""
for gen in generators:
task = gen()
if task:
should_execute = self._tasks.add(task)
if should_execute:
break # High-priority task added, execute immediately
# Don't interrupt current actions (pathfinder is handling it)
if self._is_moving:
def _execute_next_task(self):
"""Execute the highest priority task."""
task = self._tasks.next()
if not task:
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 h.get("distance", 99) < 8]
if close_hostiles and self._health < 14:
self._flee_from_hostile(close_hostiles[0])
# Special callbacks
if task.callback == "on_idle_chat":
self._handle_idle_chat(task)
self._tasks.complete()
return
# Priority 2: Wander every 4-8 seconds of idle
if idle_duration > random.uniform(4, 8):
self._wander()
if task.callback == "on_container_opened":
# Move to container first, then open it
self._execute_action(task)
return
# Priority 3: Look around when idle
if idle_duration > 2 and random.random() < 0.3:
self._look_around()
# Skip "status" placeholder actions
if task.action == "status":
self._tasks.complete()
return
def _distance_to(self, entity: dict) -> float:
"""Distance from Doug to an entity."""
return entity.get("distance", 99)
# Log significant actions
if task.description and task.priority >= Priority.LOW:
log.info(f"[{task.priority.name}] {task.description}")
def _wander(self):
"""Walk to a random nearby position using pathfinder."""
# Pick a random direction and distance (5-15 blocks)
angle = random.uniform(0, 2 * math.pi)
dist = random.uniform(5, 15)
target_x = self._position["x"] + math.cos(angle) * dist
target_z = self._position["z"] + math.sin(angle) * dist
# Execute the action
self._execute_action(task)
# 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:
# Walk back toward spawn
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) * 8
target_z = self._position["z"] + math.sin(angle) * 8
log.debug("Wandering back toward spawn")
def _execute_action(self, task: Task):
"""Send an action to the bridge."""
self._waiting_for_action = True
self._action_sent_time = time.time()
# Use pathfinder to walk there
self._ws.send_request("move_to", {
"x": target_x,
"y": self._position["y"],
"z": target_z,
"range": 2,
})
def on_response(resp: ResponseMessage):
if resp.status == "success":
# For non-movement actions, complete immediately
if task.action not in ("move_to", "move_relative", "follow_player"):
self._waiting_for_action = False
self._tasks.complete()
else:
self._waiting_for_action = False
self._tasks.cancel()
log.debug(f"Action failed: {resp.error}")
self._is_moving = True
self._action_start_time = time.time()
self._current_action = "wandering"
self._ws.send_request(task.action, task.params, on_response)
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)
def _handle_idle_chat(self, task: Task):
"""Handle unprompted chat — ask AI what to say."""
# Build context about what's happening
context_parts = []
if self._behaviors.nearby_players:
names = [p["name"] for p in self._behaviors.nearby_players]
context_parts.append(f"Players nearby: {', '.join(names)}")
if self._behaviors.is_night:
context_parts.append("It's nighttime")
if self._behaviors.is_raining:
context_parts.append("It's raining")
if self._behaviors.health < 10:
context_parts.append(f"Health is low ({self._behaviors.health})")
if self._behaviors.nearby_hostiles:
types = [h["type"] for h in self._behaviors.nearby_hostiles[:3]]
context_parts.append(f"Nearby mobs: {', '.join(types)}")
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 pathfinder."""
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))
# Pathfind 10 blocks away from mob
flee_dist = 10
flee_x = self._position["x"] + (dx / dist) * flee_dist
flee_z = self._position["z"] + (dz / dist) * flee_dist
self._ws.send_request("move_to", {
"x": flee_x,
"y": self._position["y"],
"z": flee_z,
"range": 2,
})
self._is_moving = True
self._action_start_time = time.time()
self._current_action = "fleeing"
log.info(f"Fleeing from {hostile.get('type', 'mob')}!")
context = "; ".join(context_parts) if context_parts else "Nothing special happening"
self.wants_ai_chat.emit(context, "Say something to the players nearby. Keep it natural and short.")
@property
def current_action(self) -> str:
return self._current_action
task = self._tasks.current_task
return task.name if task else "idle"
@property
def is_night(self) -> bool:
return self._day_time > 12000
return self._behaviors.is_night

143
dougbot/core/task_queue.py Normal file
View file

@ -0,0 +1,143 @@
"""
Task queue system for Doug's autonomous behavior.
Tasks have priorities, can be interrupted, and are influenced by persona traits.
"""
import time
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Any, Callable, Optional
from dougbot.utils.logging import get_logger
log = get_logger("core.task_queue")
class Priority(IntEnum):
"""Task priority levels. Higher = more urgent."""
IDLE = 0 # Looking around, wandering
LOW = 10 # Self-directed goals (explore, organize)
NORMAL = 20 # Environmental triggers (sign found, interesting block)
HIGH = 30 # Player requests via chat
URGENT = 40 # Survival (flee, eat, find shelter)
CRITICAL = 50 # Immediate danger (health critical, falling)
@dataclass
class Task:
"""A single task for Doug to perform."""
name: str
priority: Priority
action: str # Bridge action to execute
params: dict = field(default_factory=dict)
description: str = "" # Human-readable description for chat
steps: list = field(default_factory=list) # Multi-step tasks
current_step: int = 0
created_at: float = field(default_factory=time.time)
started_at: float = 0.0
timeout: float = 60.0 # Max seconds before auto-cancel
interruptible: bool = True # Can be interrupted by higher priority
callback: Optional[str] = None # Method name to call on completion
context: dict = field(default_factory=dict) # Extra data for the task
@property
def is_expired(self) -> bool:
if self.started_at > 0:
return (time.time() - self.started_at) > self.timeout
return (time.time() - self.created_at) > self.timeout * 2
@property
def age(self) -> float:
return time.time() - self.created_at
class TaskQueue:
"""Priority queue of tasks for Doug."""
def __init__(self):
self._queue: list[Task] = []
self._current: Optional[Task] = None
self._completed: list[str] = [] # Recent completed task names
self._max_completed = 20
@property
def current_task(self) -> Optional[Task]:
return self._current
@property
def is_busy(self) -> bool:
return self._current is not None
@property
def queue_size(self) -> int:
return len(self._queue)
def add(self, task: Task) -> bool:
"""Add a task to the queue. Returns True if it should interrupt current."""
# Remove expired tasks
self._queue = [t for t in self._queue if not t.is_expired]
# Don't duplicate same task
for existing in self._queue:
if existing.name == task.name and existing.action == task.action:
return False
if self._current and self._current.name == task.name:
return False
self._queue.append(task)
self._queue.sort(key=lambda t: t.priority, reverse=True)
# Check if this should interrupt current task
if self._current and task.priority > self._current.priority and self._current.interruptible:
log.info(f"Task '{task.name}' (priority {task.priority.name}) interrupts '{self._current.name}'")
# Re-queue current task
self._queue.append(self._current)
self._queue.sort(key=lambda t: t.priority, reverse=True)
self._current = None
return True
return not self.is_busy
def next(self) -> Optional[Task]:
"""Get the next task to work on."""
if self._current:
if self._current.is_expired:
log.debug(f"Task '{self._current.name}' expired")
self._current = None
else:
return self._current
# Remove expired
self._queue = [t for t in self._queue if not t.is_expired]
if not self._queue:
return None
self._current = self._queue.pop(0)
self._current.started_at = time.time()
return self._current
def complete(self, task_name: str = ""):
"""Mark current task as complete."""
if self._current:
name = self._current.name
self._completed.append(name)
if len(self._completed) > self._max_completed:
self._completed.pop(0)
self._current = None
log.debug(f"Task completed: {name}")
def cancel(self, task_name: str = ""):
"""Cancel current task."""
if self._current:
log.debug(f"Task cancelled: {self._current.name}")
self._current = None
def clear(self):
"""Clear all tasks."""
self._queue.clear()
self._current = None
def recently_completed(self, task_name: str) -> bool:
"""Check if a task was recently completed (avoid repeating)."""
return task_name in self._completed

View file

@ -356,9 +356,15 @@ 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!
# Start the brain with persona traits!
if self._ws_client and self._active_doug:
self._brain = DougBrain(self._ws_client, self._active_doug.name, parent=self)
doug = self._active_doug
traits = doug.persona_config if doug.persona_config else {}
self._brain = DougBrain(
self._ws_client, doug.name,
traits=traits, age=doug.age, parent=self,
)
self._brain.wants_ai_chat.connect(self._on_brain_wants_chat)
self._brain.update_from_event("spawn_complete", data)
self._brain.start()
self.dashboard.log_viewer.append_system("Brain activated — Doug is now autonomous!")
@ -399,6 +405,15 @@ class MainWindow(QMainWindow):
if self._brain:
self._brain.update_from_event(event, data)
# ── Brain Chat ──
def _on_brain_wants_chat(self, context: str, prompt: str):
"""Brain wants Doug to say something unprompted."""
if not self._active_doug:
return
# Use AI to generate what Doug says
self._generate_response("SYSTEM", f"[Context: {context}] {prompt}")
# ── Chat AI ──
def _should_respond(self, message: str) -> bool: