diff --git a/dougbot/core/brain.py b/dougbot/core/brain.py index f68b04a..0478d22 100644 --- a/dougbot/core/brain.py +++ b/dougbot/core/brain.py @@ -1,28 +1,33 @@ """ -Doug's Brain — goal-based autonomous decision engine. +Doug's Brain — stack-based autonomous decision engine. -Instead of randomly generating tasks each tick, Doug now has: -- A NEEDS system (safety, hunger, social, shelter, boredom) that drives urgency -- A GOALS system (long-term objectives broken into steps) -- A MEMORY system (remembers locations of things he's seen) -- A DAILY ROUTINE that adapts to time of day and persona traits +Tasks are a STACK: +- Player commands push on top (highest priority) +- Self-directed goals sit at the bottom +- Combat/flee are temporary INTERRUPTIONS that don't affect the stack +- When a task completes, Doug resumes the one below it +- Each task can have subtasks (get materials → return to building) The brain ticks every 2 seconds: 1. Update needs (decay over time) - 2. Process scan results into memory - 3. Check if current task is still running — if so, WAIT - 4. Pick the most urgent need or goal and execute the next step + 2. Scan surroundings → update memory + 3. If busy executing, WAIT + 4. Check for interrupts (combat, flee) + 5. Get next subtask from the stack and execute it + 6. If stack is empty, generate self-directed tasks """ import math import random import time -from enum import IntEnum 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.task_queue import ( + TaskStack, PrimaryTask, SubTask, Priority, TaskStatus, + make_task, make_interrupt, +) from dougbot.core.behaviors import ( NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine, ) @@ -31,19 +36,11 @@ from dougbot.utils.logging import get_logger log = get_logger("core.brain") -class BrainState(IntEnum): - """What the brain is doing right now.""" - IDLE = 0 - EXECUTING_TASK = 1 - WAITING_FOR_SCAN = 2 - - class DougBrain(QObject): - """Goal-based autonomous decision engine with needs and memory.""" + """Stack-based autonomous decision engine.""" - # Signals (same interface as before) - wants_to_chat = Signal(str) # Unprompted chat message - wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say + wants_to_chat = Signal(str) + wants_ai_chat = Signal(str, str) def __init__(self, ws_client: BridgeWSClient, doug_name: str, traits: dict = None, age: int = 30, parent=None): @@ -55,46 +52,45 @@ class DougBrain(QObject): self._running = False # Core systems - self._tasks = TaskQueue() + self._tasks = TaskStack() traits = traits or {} self._needs = NeedsSystem(traits) self._goals = GoalManager(traits, age) self._memory = SpatialMemory() self._routine = DailyRoutine(traits, age) - # BehaviorEngine is kept for compatibility (main_window accesses it) self._behaviors = BehaviorEngine(traits, age, doug_name) + self._traits = traits - # Brain state - self._state = BrainState.IDLE - self._action_sent_time = 0.0 + # Scan state self._pending_scan = False self._last_scan_time = 0.0 - self._scan_interval = 4.0 # seconds between scans + self._scan_interval = 4.0 - # Tick counter for staggering updates + # Tick counter self._tick_count = 0 # Chat throttle self._last_chat_time = 0.0 + # Action tracking + self._action_sent_time = 0.0 + def start(self): self._running = True self._tick_timer.start(2000) - # Seed some initial goals based on persona self._goals.seed_initial_goals(self._memory, self._behaviors) - log.info("Brain started — Doug is thinking (goal-based)") + log.info("Brain started — Doug is thinking (stack-based)") def stop(self): self._running = False self._tick_timer.stop() - self._tasks.clear() + self._tasks.cancel_all() self._ws.send_request("stop", {}) log.info("Brain stopped") - # ── Event handling (same interface) ── + # ── Event handling ── def update_from_event(self, event: str, data: dict): - """Update brain state from bridge events.""" if event == "spawn_complete": pos = data.get("position", {}) self._behaviors.position = { @@ -106,26 +102,22 @@ class DougBrain(QObject): elif event == "health_changed": self._behaviors.health = data.get("health", 20) self._behaviors.food = data.get("food", 20) - # Update safety need immediately on damage self._needs.on_health_change(self._behaviors.health, self._behaviors.food) elif event == "time_update": self._behaviors.day_time = data.get("dayTime", 0) elif event == "movement_complete": - self._state = BrainState.IDLE - self._tasks.complete() + self._tasks.complete_current() elif event == "movement_failed": - self._state = BrainState.IDLE - self._tasks.cancel() + self._tasks.fail_current() elif event == "death": - self._state = BrainState.IDLE - self._tasks.clear() + self._tasks.cancel_all() self._needs.on_death() self._goals.on_death() - log.info("Doug died — clearing all tasks and resetting needs") + log.info("Doug died — all tasks cleared") elif event == "player_joined": username = data.get("username", "") @@ -141,7 +133,6 @@ class DougBrain(QObject): # ── Main tick ── def _tick(self): - """Main brain tick — needs → scan → decide → act.""" from PySide6.QtNetwork import QAbstractSocket if not self._running: return @@ -150,20 +141,19 @@ class DougBrain(QObject): self._tick_count += 1 - # Safety: unstick action timeout (20s) - if self._state == BrainState.EXECUTING_TASK: - if time.time() - self._action_sent_time > 20: + # If executing a subtask, wait (with timeout safety) + if self._tasks.is_busy: + if time.time() - self._action_sent_time > 25: log.debug("Action timed out — unsticking") - self._state = BrainState.IDLE - self._tasks.cancel() + self._tasks.fail_current() else: - return # WAIT for current task to finish — don't pile on + return - # Safety: unstick pending scan + # Unstick pending scan if self._pending_scan and (time.time() - self._last_scan_time > 10): self._pending_scan = False - # Step 1: Decay needs every tick (every 2s) + # Step 1: Decay needs self._needs.decay( health=self._behaviors.health, food=self._behaviors.food, @@ -179,23 +169,24 @@ class DougBrain(QObject): self._last_scan_time = time.time() self._ws.send_request("scan_surroundings", {"radius": 16}, self._on_scan) self._ws.send_request("get_inventory", {}, self._on_inventory) - return # Wait for scan before deciding + return if self._pending_scan: - return # Still waiting for scan + return - # Step 3: Decide what to do - task = self._decide() - if task: - self._tasks.add(task) + # Step 3: Check for interrupts (combat/flee) + self._check_interrupts() - # Step 4: Execute top task from queue - self._execute_next_task() + # Step 4: If stack is empty, generate self-directed tasks + if not self._tasks.current_task: + self._generate_self_directed() - # ── Scanning & Memory ── + # Step 5: Execute next subtask from the stack + self._execute_next() + + # ── Scanning ── def _on_scan(self, response: ResponseMessage): - """Process scan results and update memory.""" self._pending_scan = False if response.status != "success": return @@ -215,7 +206,6 @@ class DougBrain(QObject): self._behaviors.nearby_entities = entities self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)] - # Update memory with what we see self._memory.update_from_scan( position=self._behaviors.position, blocks=self._behaviors.nearby_blocks, @@ -223,8 +213,6 @@ class DougBrain(QObject): players=self._behaviors.nearby_players, hostiles=self._behaviors.nearby_hostiles, ) - - # Update needs based on what we see self._needs.on_scan( hostiles_nearby=len(self._behaviors.nearby_hostiles), players_nearby=len(self._behaviors.nearby_players), @@ -235,479 +223,242 @@ class DougBrain(QObject): return self._behaviors.inventory = response.data.get("items", []) - # ── Decision Engine ── + # ── Interrupts (combat/flee — temporary, don't affect stack) ── - def _decide(self) -> Task | None: - """ - The core decision: what should Doug do RIGHT NOW? - - Priority order: - 1. Critical survival (flee, eat) — if safety/hunger need is critical - 2. Player commands (already in queue at HIGH priority) - 3. Urgent needs (shelter at night, social when lonely) - 4. Current goal step (work toward long-term goals) - 5. Daily routine activity - 6. Idle behavior (look around, chat) - """ + def _check_interrupts(self): + """Check for immediate threats that need a temporary interrupt.""" b = self._behaviors - - # 1. CRITICAL SURVIVAL — safety need below 20 - if self._needs.safety < 20: - task = self._survival_task() - if task: - return task - - # 2. HUNGER — food need below 25 - if self._needs.hunger < 25: - task = self._eat_task() - if task: - return task - - # 3. SHELTER — at night with low shelter need - if b.is_night and self._needs.shelter < 30: - task = self._shelter_task() - if task: - return task - - # 4. SOCIAL — respond to nearby players if social need is low - if self._needs.social < 30 and b.nearby_players: - task = self._social_task() - if task: - return task - - # 5. GOAL PROGRESS — work on the current goal - task = self._goal_task() - if task: - return task - - # 6. DAILY ROUTINE — what should Doug be doing at this time of day? - task = self._routine_task() - if task: - return task - - # 7. BOREDOM — do something interesting - if self._needs.boredom < 30: - task = self._boredom_task() - if task: - return task - - # 8. IDLE — look around, maybe chat - return self._idle_task() - - # ── Need-driven tasks ── - - def _survival_task(self) -> Task | None: - """Handle immediate survival threats — fight or flee based on bravery.""" - b = self._behaviors - bravery = b._traits.get("bravery", 50) + bravery = self._traits.get("bravery", 50) hostile = self._nearest_hostile(10) - if hostile: - dist = hostile.get("distance", 99) + if not hostile: + return - # FIGHT if: brave enough AND health is ok AND mob is close - should_fight = ( - bravery > 30 - and b.health > 8 - and dist < 6 - ) + dist = hostile.get("distance", 99) + mob_type = hostile.get("type", "mob") - if should_fight: - return Task( - name="combat", - priority=Priority.CRITICAL, - action="attack_nearest_hostile", - params={"range": 6}, - description=f"Fighting {hostile.get('type', 'mob')}!", - timeout=12, - interruptible=False, - ) - else: - return self._flee_task(hostile) + # FIGHT if brave enough and healthy + should_fight = bravery > 30 and b.health > 8 and dist < 6 + # FLEE if scared or hurt + should_flee = (not should_fight) and (dist < 8) and (b.health < 14 or bravery < 30) - # Critical health with no hostiles — eat if possible - if b.health <= 6: + if should_fight: + self._tasks.interrupt(make_interrupt( + "combat", f"Fighting {mob_type}!", + "attack_nearest_hostile", {"range": 6}, timeout=12, + )) + elif should_flee: + hpos = hostile.get("position", b.position) + dx = b.position["x"] - hpos.get("x", 0) + dz = b.position["z"] - hpos.get("z", 0) + d = max(0.1, math.sqrt(dx * dx + dz * dz)) + flee_x = b.position["x"] + (dx / d) * 15 + flee_z = b.position["z"] + (dz / d) * 15 + + self._tasks.interrupt(make_interrupt( + "flee", f"Fleeing from {mob_type}!", + "move_to", {"x": flee_x, "y": b.position["y"], "z": flee_z, "range": 3}, + timeout=15, + )) + + # ── Self-directed task generation ── + + def _generate_self_directed(self): + """Generate a task when the stack is empty.""" + b = self._behaviors + + # HUNGER — eat if we have food + if self._needs.hunger < 25: food = self._find_food() if food: - return Task( - name="emergency_eat", - priority=Priority.CRITICAL, - action="equip_item", - params={"name": food, "destination": "hand"}, - description=f"Emergency eating {food}", - timeout=10, + self._tasks.push(make_task( + "eat", Priority.URGENT, f"Eating {food}", + "equip_item", {"name": food, "destination": "hand"}, timeout=10, + )) + return + + # SHELTER at night + if b.is_night and self._needs.shelter < 30: + home = self._memory.home + if home and self._distance_to(home) > 10: + self._tasks.push(make_task( + "go_home", Priority.URGENT, "Heading home for the night", + "move_to", {"x": home["x"], "y": home["y"], "z": home["z"], "range": 3}, + timeout=30, + )) + return + + # SOCIAL — approach nearby players + if self._needs.social < 30 and b.nearby_players: + player = b.nearby_players[0] + dist = player.get("distance", 99) + name = player.get("name", "player") + if dist > 5: + self._tasks.push(make_task( + f"approach_{name}", Priority.NORMAL, f"Walking toward {name}", + "move_to", {**player["position"], "range": 3}, timeout=15, + )) + return + # Close — say hi (throttled) + if time.time() - self._last_chat_time > 45: + self._last_chat_time = time.time() + self._needs.social = min(100, self._needs.social + 40) + self.wants_ai_chat.emit( + self._build_chat_context(), + f"You notice {name} nearby. Say something friendly and short." ) + return - return None - - def _eat_task(self) -> Task | None: - """Find and eat food.""" - food = self._find_food() - if food: - return Task( - name="eat", - priority=Priority.URGENT, - action="equip_item", - params={"name": food, "destination": "hand"}, - description=f"Eating {food}", - timeout=10, - ) - # No food — add a goal to find some - if not self._goals.has_goal("find_food"): - self._goals.add_goal("find_food", priority=8) - return None - - def _shelter_task(self) -> Task | None: - """Seek shelter at night.""" - home = self._memory.home - if not home: - return None - - dist = self._distance_to(home) - if dist > 10: - return Task( - name="go_home", - priority=Priority.URGENT, - action="move_to", - params={"x": home["x"], "y": home["y"], "z": home["z"], "range": 3}, - description="Heading home for the night", - timeout=30, - ) - # Already near home — shelter need satisfied - self._needs.shelter = min(100, self._needs.shelter + 30) - return None - - def _social_task(self) -> Task | None: - """Interact with nearby players.""" - b = self._behaviors - if not b.nearby_players: - return None - - player = b.nearby_players[0] - dist = player.get("distance", 99) - name = player.get("name", "player") - - # Walk toward player if far - if dist > 5: - return Task( - name=f"approach_{name}", - priority=Priority.NORMAL, - action="move_to", - params={**player["position"], "range": 3}, - description=f"Walking toward {name}", - timeout=15, - ) - - # Close enough — say hi (throttled) - if time.time() - self._last_chat_time > 45: - self._last_chat_time = time.time() - self._needs.social = min(100, self._needs.social + 40) - context = self._build_chat_context() - self.wants_ai_chat.emit( - context, - f"You notice {name} nearby. Say something friendly and short." - ) - - # Social need partially satisfied just by being near people - self._needs.social = min(100, self._needs.social + 10) - return None - - def _goal_task(self) -> Task | None: - """Get the next step from the active goal.""" + # GOAL PROGRESS goal = self._goals.get_active_goal() - if not goal: - return None + if goal: + step = self._goals.get_next_step(goal, b, self._memory) + if step: + # Wrap the old Task as a PrimaryTask + task = make_task( + step.name, Priority.SELF_DIRECTED, step.description, + step.action, step.params, timeout=step.timeout, + ) + self._tasks.push(task) + return + else: + self._goals.complete_goal(goal["name"]) + self._needs.boredom = min(100, self._needs.boredom + 20) - step = self._goals.get_next_step(goal, self._behaviors, self._memory) - if not step: - # Goal complete or stuck — mark it done - self._goals.complete_goal(goal["name"]) - self._needs.boredom = min(100, self._needs.boredom + 20) - return None - - return step - - def _routine_task(self) -> Task | None: - """Get a task from the daily routine.""" - b = self._behaviors + # DAILY ROUTINE phase = self._routine.get_phase(b.day_time) + if phase == "morning" and not self._goals.has_any_goals(): + self._goals.seed_initial_goals(self._memory, b) - if phase == "morning": - # Morning: look around, plan the day - if not self._goals.has_any_goals(): - self._goals.seed_initial_goals(self._memory, b) - # Look around to survey - if random.random() < 0.3: - return self._look_around_task() - - elif phase == "day": - # Daytime: if no active goal, pick one based on what we know - if not self._goals.has_any_goals(): - self._goals.generate_goal_from_environment(self._memory, b) - # Fall through to goal_task on next tick - - elif phase == "evening": - # Evening: head toward home + if phase == "evening": home = self._memory.home if home and self._distance_to(home) > 15: - return Task( - name="evening_return", - priority=Priority.NORMAL, - action="move_to", - params={"x": home["x"], "y": home["y"], "z": home["z"], "range": 3}, - description="Heading home for the evening", + self._tasks.push(make_task( + "evening_return", Priority.NORMAL, "Heading home for the evening", + "move_to", {"x": home["x"], "y": home["y"], "z": home["z"], "range": 3}, timeout=30, - ) + )) + return - elif phase == "night": - # Night: stay near home, look around cautiously - if random.random() < 0.2: - return self._look_around_task() + # BOREDOM — explore + if self._needs.boredom < 30: + interesting = self._memory.get_nearest_unexplored(b.position, max_dist=30) + if interesting: + self._needs.boredom = min(100, self._needs.boredom + 15) + self._tasks.push(make_task( + "explore", Priority.SELF_DIRECTED, f"Checking out a {interesting['type']}", + "move_to", {"x": interesting["x"], "y": interesting["y"], + "z": interesting["z"], "range": 2}, + timeout=20, + )) + return - return None - - def _boredom_task(self) -> Task | None: - """Do something interesting to combat boredom.""" - b = self._behaviors - - # Check nearby interesting things from memory - interesting = self._memory.get_nearest_unexplored(b.position, max_dist=30) - if interesting: - self._needs.boredom = min(100, self._needs.boredom + 15) - return Task( - name=f"explore_{interesting['type']}", - priority=Priority.LOW, - action="move_to", - params={"x": interesting["x"], "y": interesting["y"], - "z": interesting["z"], "range": 2}, - description=f"Checking out a {interesting['type']}", + # Explore new direction + angle = self._memory.suggest_explore_angle(b.position) + curiosity = self._traits.get("curiosity", 50) + dist = random.uniform(8, 12 + curiosity // 10) + target_x = b.position["x"] + math.cos(angle) * dist + target_z = b.position["z"] + math.sin(angle) * dist + self._needs.boredom = min(100, self._needs.boredom + 10) + self._tasks.push(make_task( + "explore", Priority.SELF_DIRECTED, "Exploring", + "move_to", {"x": target_x, "y": b.position["y"], "z": target_z, "range": 2}, timeout=20, - ) + )) + return - # Nothing interesting — explore in a new direction - return self._explore_task() - - def _idle_task(self) -> Task | None: - """Idle behavior — look around or chat.""" - b = self._behaviors - - # Chatty behavior near players + # IDLE — look around, maybe chat if b.nearby_players and time.time() - self._last_chat_time > 60: - if random.random() < 0.15: + if random.random() < 0.1: self._last_chat_time = time.time() - context = self._build_chat_context() - self.wants_ai_chat.emit(context, "Say something casual and short.") - return None + self.wants_ai_chat.emit(self._build_chat_context(), "Say something casual.") + return - # Look around - if random.random() < 0.4: - return self._look_around_task() + if random.random() < 0.3: + self._tasks.push(make_task( + "look_around", Priority.IDLE, "", + "look_at", { + "x": b.position["x"] + random.uniform(-20, 20), + "y": b.position["y"] + random.uniform(-3, 5), + "z": b.position["z"] + random.uniform(-20, 20), + }, timeout=3, + )) - return None + # ── Execute next subtask from the stack ── - # ── Task factories ── - - def _flee_task(self, hostile: dict) -> Task: - """Run away from a hostile mob.""" - hpos = hostile.get("position", self._behaviors.position) - dx = self._behaviors.position["x"] - hpos.get("x", 0) - dz = self._behaviors.position["z"] - hpos.get("z", 0) - dist = max(0.1, math.sqrt(dx * dx + dz * dz)) - - flee_dist = 15 - flee_x = self._behaviors.position["x"] + (dx / dist) * flee_dist - flee_z = self._behaviors.position["z"] + (dz / dist) * flee_dist - - return Task( - name="flee", - priority=Priority.CRITICAL, - action="move_to", - params={"x": flee_x, "y": self._behaviors.position["y"], - "z": flee_z, "range": 3}, - description=f"Fleeing from {hostile.get('type', 'mob')}!", - timeout=15, - interruptible=False, - ) - - def _explore_task(self) -> Task: - """Explore in a direction we haven't been.""" - b = self._behaviors - curiosity = b._traits.get("curiosity", 50) - - # Pick a direction biased away from explored areas - angle = self._memory.suggest_explore_angle(b.position) - dist = random.uniform(8, 12 + curiosity // 10) - - target_x = b.position["x"] + math.cos(angle) * dist - target_z = b.position["z"] + math.sin(angle) * dist - - # Don't go too far from home - home = self._memory.home or b.spawn_pos - max_radius = 40 + curiosity // 2 - dx = target_x - home["x"] - dz = target_z - home["z"] - if math.sqrt(dx * dx + dz * dz) > max_radius: - angle = math.atan2(home["z"] - b.position["z"], - home["x"] - b.position["x"]) - target_x = b.position["x"] + math.cos(angle) * 8 - target_z = b.position["z"] + math.sin(angle) * 8 - - self._needs.boredom = min(100, self._needs.boredom + 10) - return Task( - name="explore", - priority=Priority.IDLE, - action="move_to", - params={"x": target_x, "y": b.position["y"], "z": target_z, "range": 2}, - description="Exploring", - timeout=20, - ) - - def _look_around_task(self) -> Task: - """Look in a random direction.""" - b = self._behaviors - return Task( - name="look_around", - priority=Priority.IDLE, - action="look_at", - params={ - "x": b.position["x"] + random.uniform(-20, 20), - "y": b.position["y"] + random.uniform(-3, 5), - "z": b.position["z"] + random.uniform(-20, 20), - }, - description="", - timeout=3, - ) - - # ── Task execution ── - - def _execute_next_task(self): - """Execute the highest priority task from the queue.""" - task = self._tasks.next() - if not task: - return - - # Handle special callbacks - if task.callback == "on_idle_chat": - self._handle_idle_chat(task) - self._tasks.complete() - return - - # Callbacks that need special response handling - if task.callback in ("on_equip_result", "on_inventory_report"): - # Execute then report result directly to chat (no AI) - self._execute_action_with_report(task) - return - - # Skip placeholder actions - if task.action == "status": - self._tasks.complete() + def _execute_next(self): + """Get the next subtask from the stack and send it to the bridge.""" + subtask = self._tasks.get_next_action() + if not subtask: return # Log significant actions - if task.description and task.priority >= Priority.LOW: - log.info(f"[{task.priority.name}] {task.description}") + if subtask.description: + current = self._tasks.current_task + priority_name = current.priority.name if current else "?" + log.info(f"[{priority_name}] {subtask.description}") - self._execute_action(task) - - def _execute_action(self, task: Task): - """Send an action to the bridge and mark brain as busy.""" - self._state = BrainState.EXECUTING_TASK self._action_sent_time = time.time() def on_response(resp: ResponseMessage): if resp.status == "success": data = resp.data or {} - # Craft results - if task.action == "craft_item": - self._state = BrainState.IDLE + # Craft results — report to chat + if subtask.action == "craft_item": if data.get("crafted"): item = data.get("item", "item").replace("_", " ") self._ws.send_request("send_chat", { "message": f"Done! Crafted {data.get('count', 1)} {item}." }) - self._tasks.complete() else: error = data.get("error", "Something went wrong.") self._ws.send_request("send_chat", {"message": error}) - self._tasks.cancel() + self._tasks.complete_current() return - # Instant-complete actions - if task.action in ("open_chest", "dig_block", "equip_item", - "look_at", "send_chat", "attack_nearest_hostile"): - self._state = BrainState.IDLE - self._tasks.complete() - return - - # Movement actions wait for movement_complete event - if task.action not in ("move_to", "move_relative", "follow_player"): - self._state = BrainState.IDLE - self._tasks.complete() - else: - self._state = BrainState.IDLE - self._tasks.cancel() - error = resp.error or "Something went wrong" - log.debug(f"Action failed: {error}") - if task.priority >= Priority.HIGH: - self._ws.send_request("send_chat", {"message": error}) - - self._ws.send_request(task.action, task.params, on_response) - - def _execute_action_with_report(self, task: Task): - """Execute an action and report the result directly to chat (no AI).""" - self._state = BrainState.EXECUTING_TASK - self._action_sent_time = time.time() - - if task.description and task.priority >= Priority.LOW: - log.info(f"[{task.priority.name}] {task.description}") - - def on_response(resp: ResponseMessage): - self._state = BrainState.IDLE - - if task.callback == "on_equip_result": - if resp.status == "success": - item = task.params.get("name", "item").replace("_", " ") + # Equip results — report to chat + if subtask.action == "equip_item": + item = subtask.params.get("name", "item").replace("_", " ") self._ws.send_request("send_chat", {"message": f"Equipped {item}."}) - self._tasks.complete() - else: - error = resp.error or "I don't have that item." - self._ws.send_request("send_chat", {"message": error}) - self._tasks.cancel() + self._tasks.complete_current() + return - elif task.callback == "on_inventory_report": - if resp.status == "success": - items = resp.data.get("items", []) + # Inventory check — report to chat + if subtask.action == "get_inventory": + items = data.get("items", []) if items: - # List items concisely item_strs = [f"{i['count']}x {i['name'].replace('_',' ')}" for i in items[:8]] msg = "I have: " + ", ".join(item_strs) if len(items) > 8: - msg += f" and {len(items) - 8} more items" + msg += f" and {len(items) - 8} more" self._ws.send_request("send_chat", {"message": msg}) else: self._ws.send_request("send_chat", {"message": "My inventory is empty."}) - self._tasks.complete() - else: - self._ws.send_request("send_chat", {"message": "Can't check inventory right now."}) - self._tasks.cancel() - else: - self._tasks.complete() + self._tasks.complete_current() + return - self._ws.send_request(task.action, task.params, on_response) + # Movement actions wait for movement_complete event + if subtask.action in ("move_to", "move_relative", "follow_player"): + return # Don't complete yet — wait for event + + # Everything else completes immediately + self._tasks.complete_current() + + else: + error = resp.error or "Something went wrong" + log.debug(f"Action failed: {error}") + # Report HIGH priority failures to chat + current = self._tasks.current_task + if current and current.priority >= Priority.HIGH: + self._ws.send_request("send_chat", {"message": error}) + self._tasks.fail_current() + + self._ws.send_request(subtask.action, subtask.params, on_response) # ── Helpers ── - def _handle_idle_chat(self, task: Task): - """Handle unprompted chat.""" - context = self._build_chat_context() - self.wants_ai_chat.emit( - context, - "Say something to the players nearby. Keep it natural and short." - ) - def _build_chat_context(self) -> str: - """Build a context string describing what's happening.""" parts = [] b = self._behaviors if b.nearby_players: @@ -723,19 +474,19 @@ class DougBrain(QObject): types = [h["type"] for h in b.nearby_hostiles[:3]] parts.append(f"Nearby mobs: {', '.join(types)}") - # Add goal context - goal = self._goals.get_active_goal() - if goal: - parts.append(f"Currently working on: {goal['description']}") + # Current task context + current = self._tasks.current_task + if current and current.description: + parts.append(f"Currently: {current.description}") - # Add need context - critical_needs = self._needs.get_critical_needs() - if critical_needs: - parts.append(f"Feeling: {', '.join(critical_needs)}") + # Stack depth + depth = self._tasks.stack_depth + if depth > 1: + parts.append(f"Tasks queued: {depth}") return "; ".join(parts) if parts else "Nothing special happening" - def _nearest_hostile(self, max_dist: float) -> dict | None: + def _nearest_hostile(self, max_dist: float): closest = None closest_dist = max_dist for h in self._behaviors.nearby_hostiles: @@ -748,18 +499,16 @@ class DougBrain(QObject): def _distance_to(self, pos: dict) -> float: b = self._behaviors.position dx = b["x"] - pos.get("x", 0) - dy = b["y"] - pos.get("y", 0) dz = b["z"] - pos.get("z", 0) - return math.sqrt(dx * dx + dy * dy + dz * dz) + return math.sqrt(dx * dx + dz * dz) - def _find_food(self) -> str | None: - """Find food in inventory.""" + def _find_food(self): 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", + "mushroom_stew", "rabbit_stew", "cookie", "pumpkin_pie", + "carrot", "potato", "dried_kelp", } for item in self._behaviors.inventory: if item.get("name", "").replace("minecraft:", "") in food_items: @@ -767,22 +516,17 @@ class DougBrain(QObject): return None def _is_sheltered(self) -> bool: - """Check if near home/shelter.""" home = self._memory.home if not home: return False return self._distance_to(home) < 15 - # ── Public interface (same as before) ── - @property def current_action(self) -> str: task = self._tasks.current_task if task: - return task.description or task.name - goal = self._goals.get_active_goal() - if goal: - return goal["description"] + st = task.current_subtask() + return st.description if st and st.description else task.description return "idle" @property diff --git a/dougbot/core/command_parser.py b/dougbot/core/command_parser.py index 35ee58d..3e7a114 100644 --- a/dougbot/core/command_parser.py +++ b/dougbot/core/command_parser.py @@ -7,7 +7,7 @@ import re from dataclasses import dataclass from typing import Optional -from dougbot.core.task_queue import Task, Priority +from dougbot.core.task_queue import PrimaryTask, Priority, make_task from dougbot.utils.logging import get_logger log = get_logger("core.commands") @@ -297,129 +297,87 @@ class CommandParser: return None -def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]: - """Convert a parsed command into a Task for the queue.""" +def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[PrimaryTask]: + """Convert a parsed command into a PrimaryTask for the stack.""" if cmd.action == "follow_player": - return Task( - name=f"follow_{cmd.target}", - priority=Priority.HIGH, - action="follow_player", - params={"name": cmd.target, "range": 3}, - description=f"Following {cmd.target}", - timeout=60, + task = make_task( + f"follow_{cmd.target}", Priority.HIGH, f"Following {cmd.target}", + "follow_player", {"name": cmd.target, "range": 3}, timeout=60, + source="player", open_ended=True, ) + return task elif cmd.action == "stop": - return Task( - name="stop", - priority=Priority.HIGH, - action="stop", - params={}, - description="Stopping", - timeout=5, + return make_task( + "stop", Priority.HIGH, "Stopping", + "stop", {}, timeout=5, source="player", ) elif cmd.action == "check_chest": - # Find nearest container if behaviors.nearby_containers: container = behaviors.nearby_containers[0] - return Task( - name="check_chest", - priority=Priority.HIGH, - action="open_chest", - params=container["position"], - description="Checking the chest", - timeout=15, - callback="on_container_opened", + return make_task( + "check_chest", Priority.HIGH, "Checking the chest", + "open_chest", container["position"], timeout=15, source="player", ) elif cmd.action == "sort_chest": if behaviors.nearby_containers: container = behaviors.nearby_containers[0] - return Task( - name="sort_chest", - priority=Priority.HIGH, - action="open_chest", - params=container["position"], - description="Sorting the chest", - timeout=30, - callback="on_sort_container", + return make_task( + "sort_chest", Priority.HIGH, "Sorting the chest", + "open_chest", container["position"], timeout=30, source="player", ) elif cmd.action == "craft": count = cmd.params.get("count", 1) if cmd.params else 1 - return Task( - name=f"craft_{cmd.target}", - priority=Priority.HIGH, - action="craft_item", - params={"itemName": cmd.target, "count": count}, - description=f"Crafting {count}x {cmd.target.replace('_', ' ')}", - timeout=30, + return make_task( + f"craft_{cmd.target}", Priority.HIGH, + f"Crafting {count}x {cmd.target.replace('_', ' ')}", + "craft_item", {"itemName": cmd.target, "count": count}, + timeout=30, source="player", ) elif cmd.action == "mine": - return Task( - name=f"mine_{cmd.target}", - priority=Priority.HIGH, - action="find_blocks", - params={"blockName": cmd.target, "radius": 16, "count": 1}, - description=f"Looking for {cmd.target.replace('_', ' ')} to mine", - timeout=30, - callback="on_found_blocks_to_mine", + return make_task( + f"mine_{cmd.target}", Priority.HIGH, + f"Looking for {cmd.target.replace('_', ' ')} to mine", + "find_blocks", {"blockName": cmd.target, "radius": 16, "count": 1}, + timeout=30, source="player", ) elif cmd.action == "attack": - return Task( - name="attack_command", - priority=Priority.HIGH, - action="attack_nearest_hostile", - params={"range": 8}, - description=f"Attacking!", - timeout=10, + return make_task( + "attack_command", Priority.HIGH, "Attacking!", + "attack_nearest_hostile", {"range": 8}, timeout=10, source="player", ) elif cmd.action == "look_around": - return Task( - name="look_around_command", - priority=Priority.HIGH, - action="scan_surroundings", - params={"radius": 16}, - description="Looking around", - timeout=10, - callback="on_look_around_report", + return make_task( + "look_around_command", Priority.HIGH, "Looking around", + "scan_surroundings", {"radius": 16}, timeout=10, source="player", ) elif cmd.action == "equip": - return Task( - name=f"equip_{cmd.target}", - priority=Priority.HIGH, - action="equip_item", - params={"name": cmd.target, "destination": "hand"}, - description=f"Equipping {cmd.target.replace('_', ' ')}", - timeout=10, - callback="on_equip_result", + return make_task( + f"equip_{cmd.target}", Priority.HIGH, + f"Equipping {cmd.target.replace('_', ' ')}", + "equip_item", {"name": cmd.target, "destination": "hand"}, + timeout=10, source="player", ) elif cmd.action == "check_inventory": - return Task( - name="check_inventory", - priority=Priority.HIGH, - action="get_inventory", - params={}, - description="Checking inventory", - timeout=10, - callback="on_inventory_report", + return make_task( + "check_inventory", Priority.HIGH, "Checking inventory", + "get_inventory", {}, timeout=10, source="player", ) elif cmd.action == "go_to": - return Task( - name=f"go_to_{cmd.target}", - priority=Priority.HIGH, - action="follow_player", - params={"name": cmd.target, "range": 3}, - description=f"Going to {cmd.target}", - timeout=30, + return make_task( + f"go_to_{cmd.target}", Priority.HIGH, f"Going to {cmd.target}", + "follow_player", {"name": cmd.target, "range": 3}, + timeout=30, source="player", ) return None diff --git a/dougbot/gui/main_window.py b/dougbot/gui/main_window.py index 3d3a180..631f872 100644 --- a/dougbot/gui/main_window.py +++ b/dougbot/gui/main_window.py @@ -449,7 +449,7 @@ class MainWindow(QMainWindow): return False # Add task to brain's queue — task runs first, then Doug responds - self._brain._tasks.add(task) + self._brain._tasks.push(task) log.info(f"Command from {sender}: {cmd.action} → task '{task.name}'") # Short acknowledgment ONLY — no AI call, just a quick "on it" type response