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:
parent
61c4a919d2
commit
1c8e2a7c90
3 changed files with 288 additions and 586 deletions
|
|
@ -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")
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
# Critical health with no hostiles — eat if possible
|
||||
if b.health <= 6:
|
||||
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.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,
|
||||
))
|
||||
|
||||
return None
|
||||
# ── Self-directed task generation ──
|
||||
|
||||
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."""
|
||||
def _generate_self_directed(self):
|
||||
"""Generate a task when the stack is empty."""
|
||||
b = self._behaviors
|
||||
if not b.nearby_players:
|
||||
return None
|
||||
|
||||
# HUNGER — eat if we have food
|
||||
if self._needs.hunger < 25:
|
||||
food = self._find_food()
|
||||
if food:
|
||||
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")
|
||||
|
||||
# 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)
|
||||
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)
|
||||
context = self._build_chat_context()
|
||||
self.wants_ai_chat.emit(
|
||||
context,
|
||||
self._build_chat_context(),
|
||||
f"You notice {name} nearby. Say something friendly and short."
|
||||
)
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
step = self._goals.get_next_step(goal, self._behaviors, self._memory)
|
||||
if not step:
|
||||
# Goal complete or stuck — mark it done
|
||||
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)
|
||||
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":
|
||||
# Morning: look around, plan the day
|
||||
if not self._goals.has_any_goals():
|
||||
if phase == "morning" and 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()
|
||||
|
||||
return None
|
||||
|
||||
def _boredom_task(self) -> Task | None:
|
||||
"""Do something interesting to combat boredom."""
|
||||
b = self._behaviors
|
||||
|
||||
# Check nearby interesting things from memory
|
||||
# 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)
|
||||
return Task(
|
||||
name=f"explore_{interesting['type']}",
|
||||
priority=Priority.LOW,
|
||||
action="move_to",
|
||||
params={"x": interesting["x"], "y": interesting["y"],
|
||||
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},
|
||||
description=f"Checking out a {interesting['type']}",
|
||||
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
|
||||
if b.nearby_players and time.time() - self._last_chat_time > 60:
|
||||
if random.random() < 0.15:
|
||||
self._last_chat_time = time.time()
|
||||
context = self._build_chat_context()
|
||||
self.wants_ai_chat.emit(context, "Say something casual and short.")
|
||||
return None
|
||||
|
||||
# Look around
|
||||
if random.random() < 0.4:
|
||||
return self._look_around_task()
|
||||
|
||||
return None
|
||||
|
||||
# ── 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
|
||||
# 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
|
||||
|
||||
# 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",
|
||||
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
|
||||
|
||||
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={
|
||||
# IDLE — look around, maybe chat
|
||||
if b.nearby_players and time.time() - self._last_chat_time > 60:
|
||||
if random.random() < 0.1:
|
||||
self._last_chat_time = time.time()
|
||||
self.wants_ai_chat.emit(self._build_chat_context(), "Say something casual.")
|
||||
return
|
||||
|
||||
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),
|
||||
},
|
||||
description="",
|
||||
timeout=3,
|
||||
)
|
||||
}, timeout=3,
|
||||
))
|
||||
|
||||
# ── Task execution ──
|
||||
# ── Execute next subtask from the stack ──
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue