Wire TaskStack into brain — tasks persist and resume

Brain rewritten to use TaskStack instead of TaskQueue:
- Combat/flee use interrupt() — temporary, don't affect the stack
- Player commands use push() — go on top, pause current task
- When task completes, previous task RESUMES automatically
- Self-directed goals push at SELF_DIRECTED priority (bottom)
- "follow me" is open_ended — stays active until cancelled or new command

Flow example:
  Doug exploring (self-directed)
  → Player says "follow me" → exploring paused, following starts
  → Zombie attacks → interrupt: fight → following resumes
  → Player says "stop" → following cancelled, exploring resumes

Command parser updated to create PrimaryTask objects via make_task()
All command tasks marked source="player" for priority handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 17:32:10 -05:00
parent 61c4a919d2
commit 1c8e2a7c90
3 changed files with 288 additions and 586 deletions

View file

@ -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: Tasks are a STACK:
- A NEEDS system (safety, hunger, social, shelter, boredom) that drives urgency - Player commands push on top (highest priority)
- A GOALS system (long-term objectives broken into steps) - Self-directed goals sit at the bottom
- A MEMORY system (remembers locations of things he's seen) - Combat/flee are temporary INTERRUPTIONS that don't affect the stack
- A DAILY ROUTINE that adapts to time of day and persona traits - 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: The brain ticks every 2 seconds:
1. Update needs (decay over time) 1. Update needs (decay over time)
2. Process scan results into memory 2. Scan surroundings update memory
3. Check if current task is still running if so, WAIT 3. If busy executing, WAIT
4. Pick the most urgent need or goal and execute the next step 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 math
import random import random
import time import time
from enum import IntEnum
from PySide6.QtCore import QObject, QTimer, Signal from PySide6.QtCore import QObject, QTimer, Signal
from dougbot.bridge.ws_client import BridgeWSClient from dougbot.bridge.ws_client import BridgeWSClient
from dougbot.bridge.protocol import ResponseMessage 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 ( from dougbot.core.behaviors import (
NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine, NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine,
) )
@ -31,19 +36,11 @@ from dougbot.utils.logging import get_logger
log = get_logger("core.brain") 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): 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)
wants_to_chat = Signal(str) # Unprompted chat message wants_ai_chat = Signal(str, str)
wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say
def __init__(self, ws_client: BridgeWSClient, doug_name: str, def __init__(self, ws_client: BridgeWSClient, doug_name: str,
traits: dict = None, age: int = 30, parent=None): traits: dict = None, age: int = 30, parent=None):
@ -55,46 +52,45 @@ class DougBrain(QObject):
self._running = False self._running = False
# Core systems # Core systems
self._tasks = TaskQueue() self._tasks = TaskStack()
traits = traits or {} traits = traits or {}
self._needs = NeedsSystem(traits) self._needs = NeedsSystem(traits)
self._goals = GoalManager(traits, age) self._goals = GoalManager(traits, age)
self._memory = SpatialMemory() self._memory = SpatialMemory()
self._routine = DailyRoutine(traits, age) self._routine = DailyRoutine(traits, age)
# BehaviorEngine is kept for compatibility (main_window accesses it)
self._behaviors = BehaviorEngine(traits, age, doug_name) self._behaviors = BehaviorEngine(traits, age, doug_name)
self._traits = traits
# Brain state # Scan state
self._state = BrainState.IDLE
self._action_sent_time = 0.0
self._pending_scan = False self._pending_scan = False
self._last_scan_time = 0.0 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 self._tick_count = 0
# Chat throttle # Chat throttle
self._last_chat_time = 0.0 self._last_chat_time = 0.0
# Action tracking
self._action_sent_time = 0.0
def start(self): def start(self):
self._running = True self._running = True
self._tick_timer.start(2000) self._tick_timer.start(2000)
# Seed some initial goals based on persona
self._goals.seed_initial_goals(self._memory, self._behaviors) 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): def stop(self):
self._running = False self._running = False
self._tick_timer.stop() self._tick_timer.stop()
self._tasks.clear() self._tasks.cancel_all()
self._ws.send_request("stop", {}) self._ws.send_request("stop", {})
log.info("Brain stopped") log.info("Brain stopped")
# ── Event handling (same interface) ── # ── Event handling ──
def update_from_event(self, event: str, data: dict): def update_from_event(self, event: str, data: dict):
"""Update brain state from bridge events."""
if event == "spawn_complete": if event == "spawn_complete":
pos = data.get("position", {}) pos = data.get("position", {})
self._behaviors.position = { self._behaviors.position = {
@ -106,26 +102,22 @@ class DougBrain(QObject):
elif event == "health_changed": elif event == "health_changed":
self._behaviors.health = data.get("health", 20) self._behaviors.health = data.get("health", 20)
self._behaviors.food = data.get("food", 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) self._needs.on_health_change(self._behaviors.health, self._behaviors.food)
elif event == "time_update": elif event == "time_update":
self._behaviors.day_time = data.get("dayTime", 0) self._behaviors.day_time = data.get("dayTime", 0)
elif event == "movement_complete": elif event == "movement_complete":
self._state = BrainState.IDLE self._tasks.complete_current()
self._tasks.complete()
elif event == "movement_failed": elif event == "movement_failed":
self._state = BrainState.IDLE self._tasks.fail_current()
self._tasks.cancel()
elif event == "death": elif event == "death":
self._state = BrainState.IDLE self._tasks.cancel_all()
self._tasks.clear()
self._needs.on_death() self._needs.on_death()
self._goals.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": elif event == "player_joined":
username = data.get("username", "") username = data.get("username", "")
@ -141,7 +133,6 @@ class DougBrain(QObject):
# ── Main tick ── # ── Main tick ──
def _tick(self): def _tick(self):
"""Main brain tick — needs → scan → decide → act."""
from PySide6.QtNetwork import QAbstractSocket from PySide6.QtNetwork import QAbstractSocket
if not self._running: if not self._running:
return return
@ -150,20 +141,19 @@ class DougBrain(QObject):
self._tick_count += 1 self._tick_count += 1
# Safety: unstick action timeout (20s) # If executing a subtask, wait (with timeout safety)
if self._state == BrainState.EXECUTING_TASK: if self._tasks.is_busy:
if time.time() - self._action_sent_time > 20: if time.time() - self._action_sent_time > 25:
log.debug("Action timed out — unsticking") log.debug("Action timed out — unsticking")
self._state = BrainState.IDLE self._tasks.fail_current()
self._tasks.cancel()
else: 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): if self._pending_scan and (time.time() - self._last_scan_time > 10):
self._pending_scan = False self._pending_scan = False
# Step 1: Decay needs every tick (every 2s) # Step 1: Decay needs
self._needs.decay( self._needs.decay(
health=self._behaviors.health, health=self._behaviors.health,
food=self._behaviors.food, food=self._behaviors.food,
@ -179,23 +169,24 @@ class DougBrain(QObject):
self._last_scan_time = time.time() self._last_scan_time = time.time()
self._ws.send_request("scan_surroundings", {"radius": 16}, self._on_scan) self._ws.send_request("scan_surroundings", {"radius": 16}, self._on_scan)
self._ws.send_request("get_inventory", {}, self._on_inventory) self._ws.send_request("get_inventory", {}, self._on_inventory)
return # Wait for scan before deciding return
if self._pending_scan: if self._pending_scan:
return # Still waiting for scan return
# Step 3: Decide what to do # Step 3: Check for interrupts (combat/flee)
task = self._decide() self._check_interrupts()
if task:
self._tasks.add(task)
# Step 4: Execute top task from queue # Step 4: If stack is empty, generate self-directed tasks
self._execute_next_task() 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): def _on_scan(self, response: ResponseMessage):
"""Process scan results and update memory."""
self._pending_scan = False self._pending_scan = False
if response.status != "success": if response.status != "success":
return return
@ -215,7 +206,6 @@ class DougBrain(QObject):
self._behaviors.nearby_entities = entities self._behaviors.nearby_entities = entities
self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)] 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( self._memory.update_from_scan(
position=self._behaviors.position, position=self._behaviors.position,
blocks=self._behaviors.nearby_blocks, blocks=self._behaviors.nearby_blocks,
@ -223,8 +213,6 @@ class DougBrain(QObject):
players=self._behaviors.nearby_players, players=self._behaviors.nearby_players,
hostiles=self._behaviors.nearby_hostiles, hostiles=self._behaviors.nearby_hostiles,
) )
# Update needs based on what we see
self._needs.on_scan( self._needs.on_scan(
hostiles_nearby=len(self._behaviors.nearby_hostiles), hostiles_nearby=len(self._behaviors.nearby_hostiles),
players_nearby=len(self._behaviors.nearby_players), players_nearby=len(self._behaviors.nearby_players),
@ -235,479 +223,242 @@ class DougBrain(QObject):
return return
self._behaviors.inventory = response.data.get("items", []) self._behaviors.inventory = response.data.get("items", [])
# ── Decision Engine ── # ── Interrupts (combat/flee — temporary, don't affect stack) ──
def _decide(self) -> Task | None: def _check_interrupts(self):
""" """Check for immediate threats that need a temporary interrupt."""
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)
"""
b = self._behaviors b = self._behaviors
bravery = self._traits.get("bravery", 50)
# 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)
hostile = self._nearest_hostile(10) hostile = self._nearest_hostile(10)
if hostile: if not hostile:
dist = hostile.get("distance", 99) return
# FIGHT if: brave enough AND health is ok AND mob is close dist = hostile.get("distance", 99)
should_fight = ( mob_type = hostile.get("type", "mob")
bravery > 30
and b.health > 8
and dist < 6
)
if should_fight: # FIGHT if brave enough and healthy
return Task( should_fight = bravery > 30 and b.health > 8 and dist < 6
name="combat", # FLEE if scared or hurt
priority=Priority.CRITICAL, should_flee = (not should_fight) and (dist < 8) and (b.health < 14 or bravery < 30)
action="attack_nearest_hostile",
params={"range": 6},
description=f"Fighting {hostile.get('type', 'mob')}!",
timeout=12,
interruptible=False,
)
else:
return self._flee_task(hostile)
# Critical health with no hostiles — eat if possible if should_fight:
if b.health <= 6: 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() food = self._find_food()
if food: if food:
return Task( self._tasks.push(make_task(
name="emergency_eat", "eat", Priority.URGENT, f"Eating {food}",
priority=Priority.CRITICAL, "equip_item", {"name": food, "destination": "hand"}, timeout=10,
action="equip_item", ))
params={"name": food, "destination": "hand"}, return
description=f"Emergency eating {food}",
timeout=10, # 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 # GOAL PROGRESS
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 = self._goals.get_active_goal() goal = self._goals.get_active_goal()
if not goal: if goal:
return None 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) # DAILY ROUTINE
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
phase = self._routine.get_phase(b.day_time) 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": if phase == "evening":
# 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
home = self._memory.home home = self._memory.home
if home and self._distance_to(home) > 15: if home and self._distance_to(home) > 15:
return Task( self._tasks.push(make_task(
name="evening_return", "evening_return", Priority.NORMAL, "Heading home for the evening",
priority=Priority.NORMAL, "move_to", {"x": home["x"], "y": home["y"], "z": home["z"], "range": 3},
action="move_to",
params={"x": home["x"], "y": home["y"], "z": home["z"], "range": 3},
description="Heading home for the evening",
timeout=30, timeout=30,
) ))
return
elif phase == "night": # BOREDOM — explore
# Night: stay near home, look around cautiously if self._needs.boredom < 30:
if random.random() < 0.2: interesting = self._memory.get_nearest_unexplored(b.position, max_dist=30)
return self._look_around_task() 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 # Explore new direction
angle = self._memory.suggest_explore_angle(b.position)
def _boredom_task(self) -> Task | None: curiosity = self._traits.get("curiosity", 50)
"""Do something interesting to combat boredom.""" dist = random.uniform(8, 12 + curiosity // 10)
b = self._behaviors target_x = b.position["x"] + math.cos(angle) * dist
target_z = b.position["z"] + math.sin(angle) * dist
# Check nearby interesting things from memory self._needs.boredom = min(100, self._needs.boredom + 10)
interesting = self._memory.get_nearest_unexplored(b.position, max_dist=30) self._tasks.push(make_task(
if interesting: "explore", Priority.SELF_DIRECTED, "Exploring",
self._needs.boredom = min(100, self._needs.boredom + 15) "move_to", {"x": target_x, "y": b.position["y"], "z": target_z, "range": 2},
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']}",
timeout=20, timeout=20,
) ))
return
# Nothing interesting — explore in a new direction # IDLE — look around, maybe chat
return self._explore_task()
def _idle_task(self) -> Task | None:
"""Idle behavior — look around or chat."""
b = self._behaviors
# Chatty behavior near players
if b.nearby_players and time.time() - self._last_chat_time > 60: 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() self._last_chat_time = time.time()
context = self._build_chat_context() self.wants_ai_chat.emit(self._build_chat_context(), "Say something casual.")
self.wants_ai_chat.emit(context, "Say something casual and short.") return
return None
# Look around if random.random() < 0.3:
if random.random() < 0.4: self._tasks.push(make_task(
return self._look_around_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 _execute_next(self):
"""Get the next subtask from the stack and send it to the bridge."""
def _flee_task(self, hostile: dict) -> Task: subtask = self._tasks.get_next_action()
"""Run away from a hostile mob.""" if not subtask:
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()
return return
# Log significant actions # Log significant actions
if task.description and task.priority >= Priority.LOW: if subtask.description:
log.info(f"[{task.priority.name}] {task.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() self._action_sent_time = time.time()
def on_response(resp: ResponseMessage): def on_response(resp: ResponseMessage):
if resp.status == "success": if resp.status == "success":
data = resp.data or {} data = resp.data or {}
# Craft results # Craft results — report to chat
if task.action == "craft_item": if subtask.action == "craft_item":
self._state = BrainState.IDLE
if data.get("crafted"): if data.get("crafted"):
item = data.get("item", "item").replace("_", " ") item = data.get("item", "item").replace("_", " ")
self._ws.send_request("send_chat", { self._ws.send_request("send_chat", {
"message": f"Done! Crafted {data.get('count', 1)} {item}." "message": f"Done! Crafted {data.get('count', 1)} {item}."
}) })
self._tasks.complete()
else: else:
error = data.get("error", "Something went wrong.") error = data.get("error", "Something went wrong.")
self._ws.send_request("send_chat", {"message": error}) self._ws.send_request("send_chat", {"message": error})
self._tasks.cancel() self._tasks.complete_current()
return return
# Instant-complete actions # Equip results — report to chat
if task.action in ("open_chest", "dig_block", "equip_item", if subtask.action == "equip_item":
"look_at", "send_chat", "attack_nearest_hostile"): item = subtask.params.get("name", "item").replace("_", " ")
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("_", " ")
self._ws.send_request("send_chat", {"message": f"Equipped {item}."}) self._ws.send_request("send_chat", {"message": f"Equipped {item}."})
self._tasks.complete() self._tasks.complete_current()
else: return
error = resp.error or "I don't have that item."
self._ws.send_request("send_chat", {"message": error})
self._tasks.cancel()
elif task.callback == "on_inventory_report": # Inventory check — report to chat
if resp.status == "success": if subtask.action == "get_inventory":
items = resp.data.get("items", []) items = data.get("items", [])
if items: if items:
# List items concisely
item_strs = [f"{i['count']}x {i['name'].replace('_',' ')}" for i in items[:8]] item_strs = [f"{i['count']}x {i['name'].replace('_',' ')}" for i in items[:8]]
msg = "I have: " + ", ".join(item_strs) msg = "I have: " + ", ".join(item_strs)
if len(items) > 8: 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}) self._ws.send_request("send_chat", {"message": msg})
else: else:
self._ws.send_request("send_chat", {"message": "My inventory is empty."}) self._ws.send_request("send_chat", {"message": "My inventory is empty."})
self._tasks.complete() self._tasks.complete_current()
else: return
self._ws.send_request("send_chat", {"message": "Can't check inventory right now."})
self._tasks.cancel()
else:
self._tasks.complete()
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 ── # ── 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: def _build_chat_context(self) -> str:
"""Build a context string describing what's happening."""
parts = [] parts = []
b = self._behaviors b = self._behaviors
if b.nearby_players: if b.nearby_players:
@ -723,19 +474,19 @@ class DougBrain(QObject):
types = [h["type"] for h in b.nearby_hostiles[:3]] types = [h["type"] for h in b.nearby_hostiles[:3]]
parts.append(f"Nearby mobs: {', '.join(types)}") parts.append(f"Nearby mobs: {', '.join(types)}")
# Add goal context # Current task context
goal = self._goals.get_active_goal() current = self._tasks.current_task
if goal: if current and current.description:
parts.append(f"Currently working on: {goal['description']}") parts.append(f"Currently: {current.description}")
# Add need context # Stack depth
critical_needs = self._needs.get_critical_needs() depth = self._tasks.stack_depth
if critical_needs: if depth > 1:
parts.append(f"Feeling: {', '.join(critical_needs)}") parts.append(f"Tasks queued: {depth}")
return "; ".join(parts) if parts else "Nothing special happening" 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 = None
closest_dist = max_dist closest_dist = max_dist
for h in self._behaviors.nearby_hostiles: for h in self._behaviors.nearby_hostiles:
@ -748,18 +499,16 @@ class DougBrain(QObject):
def _distance_to(self, pos: dict) -> float: def _distance_to(self, pos: dict) -> float:
b = self._behaviors.position b = self._behaviors.position
dx = b["x"] - pos.get("x", 0) dx = b["x"] - pos.get("x", 0)
dy = b["y"] - pos.get("y", 0)
dz = b["z"] - pos.get("z", 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: def _find_food(self):
"""Find food in inventory."""
food_items = { food_items = {
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton", "cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple", "cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
"golden_apple", "melon_slice", "sweet_berries", "baked_potato", "golden_apple", "melon_slice", "sweet_berries", "baked_potato",
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie", "mushroom_stew", "rabbit_stew", "cookie", "pumpkin_pie",
"pumpkin_pie", "cake", "dried_kelp", "carrot", "potato", "carrot", "potato", "dried_kelp",
} }
for item in self._behaviors.inventory: for item in self._behaviors.inventory:
if item.get("name", "").replace("minecraft:", "") in food_items: if item.get("name", "").replace("minecraft:", "") in food_items:
@ -767,22 +516,17 @@ class DougBrain(QObject):
return None return None
def _is_sheltered(self) -> bool: def _is_sheltered(self) -> bool:
"""Check if near home/shelter."""
home = self._memory.home home = self._memory.home
if not home: if not home:
return False return False
return self._distance_to(home) < 15 return self._distance_to(home) < 15
# ── Public interface (same as before) ──
@property @property
def current_action(self) -> str: def current_action(self) -> str:
task = self._tasks.current_task task = self._tasks.current_task
if task: if task:
return task.description or task.name st = task.current_subtask()
goal = self._goals.get_active_goal() return st.description if st and st.description else task.description
if goal:
return goal["description"]
return "idle" return "idle"
@property @property

View file

@ -7,7 +7,7 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional 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 from dougbot.utils.logging import get_logger
log = get_logger("core.commands") log = get_logger("core.commands")
@ -297,129 +297,87 @@ class CommandParser:
return None return None
def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]: def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[PrimaryTask]:
"""Convert a parsed command into a Task for the queue.""" """Convert a parsed command into a PrimaryTask for the stack."""
if cmd.action == "follow_player": if cmd.action == "follow_player":
return Task( task = make_task(
name=f"follow_{cmd.target}", f"follow_{cmd.target}", Priority.HIGH, f"Following {cmd.target}",
priority=Priority.HIGH, "follow_player", {"name": cmd.target, "range": 3}, timeout=60,
action="follow_player", source="player", open_ended=True,
params={"name": cmd.target, "range": 3},
description=f"Following {cmd.target}",
timeout=60,
) )
return task
elif cmd.action == "stop": elif cmd.action == "stop":
return Task( return make_task(
name="stop", "stop", Priority.HIGH, "Stopping",
priority=Priority.HIGH, "stop", {}, timeout=5, source="player",
action="stop",
params={},
description="Stopping",
timeout=5,
) )
elif cmd.action == "check_chest": elif cmd.action == "check_chest":
# Find nearest container
if behaviors.nearby_containers: if behaviors.nearby_containers:
container = behaviors.nearby_containers[0] container = behaviors.nearby_containers[0]
return Task( return make_task(
name="check_chest", "check_chest", Priority.HIGH, "Checking the chest",
priority=Priority.HIGH, "open_chest", container["position"], timeout=15, source="player",
action="open_chest",
params=container["position"],
description="Checking the chest",
timeout=15,
callback="on_container_opened",
) )
elif cmd.action == "sort_chest": elif cmd.action == "sort_chest":
if behaviors.nearby_containers: if behaviors.nearby_containers:
container = behaviors.nearby_containers[0] container = behaviors.nearby_containers[0]
return Task( return make_task(
name="sort_chest", "sort_chest", Priority.HIGH, "Sorting the chest",
priority=Priority.HIGH, "open_chest", container["position"], timeout=30, source="player",
action="open_chest",
params=container["position"],
description="Sorting the chest",
timeout=30,
callback="on_sort_container",
) )
elif cmd.action == "craft": elif cmd.action == "craft":
count = cmd.params.get("count", 1) if cmd.params else 1 count = cmd.params.get("count", 1) if cmd.params else 1
return Task( return make_task(
name=f"craft_{cmd.target}", f"craft_{cmd.target}", Priority.HIGH,
priority=Priority.HIGH, f"Crafting {count}x {cmd.target.replace('_', ' ')}",
action="craft_item", "craft_item", {"itemName": cmd.target, "count": count},
params={"itemName": cmd.target, "count": count}, timeout=30, source="player",
description=f"Crafting {count}x {cmd.target.replace('_', ' ')}",
timeout=30,
) )
elif cmd.action == "mine": elif cmd.action == "mine":
return Task( return make_task(
name=f"mine_{cmd.target}", f"mine_{cmd.target}", Priority.HIGH,
priority=Priority.HIGH, f"Looking for {cmd.target.replace('_', ' ')} to mine",
action="find_blocks", "find_blocks", {"blockName": cmd.target, "radius": 16, "count": 1},
params={"blockName": cmd.target, "radius": 16, "count": 1}, timeout=30, source="player",
description=f"Looking for {cmd.target.replace('_', ' ')} to mine",
timeout=30,
callback="on_found_blocks_to_mine",
) )
elif cmd.action == "attack": elif cmd.action == "attack":
return Task( return make_task(
name="attack_command", "attack_command", Priority.HIGH, "Attacking!",
priority=Priority.HIGH, "attack_nearest_hostile", {"range": 8}, timeout=10, source="player",
action="attack_nearest_hostile",
params={"range": 8},
description=f"Attacking!",
timeout=10,
) )
elif cmd.action == "look_around": elif cmd.action == "look_around":
return Task( return make_task(
name="look_around_command", "look_around_command", Priority.HIGH, "Looking around",
priority=Priority.HIGH, "scan_surroundings", {"radius": 16}, timeout=10, source="player",
action="scan_surroundings",
params={"radius": 16},
description="Looking around",
timeout=10,
callback="on_look_around_report",
) )
elif cmd.action == "equip": elif cmd.action == "equip":
return Task( return make_task(
name=f"equip_{cmd.target}", f"equip_{cmd.target}", Priority.HIGH,
priority=Priority.HIGH, f"Equipping {cmd.target.replace('_', ' ')}",
action="equip_item", "equip_item", {"name": cmd.target, "destination": "hand"},
params={"name": cmd.target, "destination": "hand"}, timeout=10, source="player",
description=f"Equipping {cmd.target.replace('_', ' ')}",
timeout=10,
callback="on_equip_result",
) )
elif cmd.action == "check_inventory": elif cmd.action == "check_inventory":
return Task( return make_task(
name="check_inventory", "check_inventory", Priority.HIGH, "Checking inventory",
priority=Priority.HIGH, "get_inventory", {}, timeout=10, source="player",
action="get_inventory",
params={},
description="Checking inventory",
timeout=10,
callback="on_inventory_report",
) )
elif cmd.action == "go_to": elif cmd.action == "go_to":
return Task( return make_task(
name=f"go_to_{cmd.target}", f"go_to_{cmd.target}", Priority.HIGH, f"Going to {cmd.target}",
priority=Priority.HIGH, "follow_player", {"name": cmd.target, "range": 3},
action="follow_player", timeout=30, source="player",
params={"name": cmd.target, "range": 3},
description=f"Going to {cmd.target}",
timeout=30,
) )
return None return None

View file

@ -449,7 +449,7 @@ class MainWindow(QMainWindow):
return False return False
# Add task to brain's queue — task runs first, then Doug responds # 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}'") log.info(f"Command from {sender}: {cmd.action} → task '{task.name}'")
# Short acknowledgment ONLY — no AI call, just a quick "on it" type response # Short acknowledgment ONLY — no AI call, just a quick "on it" type response