From b609d4c896e01d7decd8c942646759545d1c33fd Mon Sep 17 00:00:00 2001 From: roberts Date: Mon, 30 Mar 2026 12:48:15 -0500 Subject: [PATCH] Phase 3: Task queue, behavior engine, trait-driven decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .claude/settings.local.json | 5 +- bridge/src/index.js | 221 +++++++++++++++++++++ dougbot/core/behaviors.py | 379 ++++++++++++++++++++++++++++++++++++ dougbot/core/brain.py | 339 ++++++++++++++++---------------- dougbot/core/task_queue.py | 143 ++++++++++++++ dougbot/gui/main_window.py | 19 +- 6 files changed, 935 insertions(+), 171 deletions(-) create mode 100644 dougbot/core/behaviors.py create mode 100644 dougbot/core/task_queue.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5395a05..272f324 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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:*)" ] } } diff --git a/bridge/src/index.js b/bridge/src/index.js index 346fbfa..9ddf713 100644 --- a/bridge/src/index.js +++ b/bridge/src/index.js @@ -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}`); } diff --git a/dougbot/core/behaviors.py b/dougbot/core/behaviors.py new file mode 100644 index 0000000..042d8b8 --- /dev/null +++ b/dougbot/core/behaviors.py @@ -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 diff --git a/dougbot/core/brain.py b/dougbot/core/brain.py index 089339b..8f9cc88 100644 --- a/dougbot/core/brain.py +++ b/dougbot/core/brain.py @@ -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 diff --git a/dougbot/core/task_queue.py b/dougbot/core/task_queue.py new file mode 100644 index 0000000..46ee9e4 --- /dev/null +++ b/dougbot/core/task_queue.py @@ -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 diff --git a/dougbot/gui/main_window.py b/dougbot/gui/main_window.py index d09af69..d9ab706 100644 --- a/dougbot/gui/main_window.py +++ b/dougbot/gui/main_window.py @@ -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: