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:
parent
8f616598fd
commit
b609d4c896
6 changed files with 935 additions and 171 deletions
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
379
dougbot/core/behaviors.py
Normal 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
|
||||
|
|
@ -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
143
dougbot/core/task_queue.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue