Major overhaul: combat fix, smart brain, Ollama stability, crafting

COMBAT:
- Fixed attack serialization: proper heldItemToNotch() for empty hand
- Attack now looks at target before swinging (server validates aim)
- Player position includes eye height in attack packet
- Click position calculated relative to target entity

INTELLIGENCE (complete rewrite):
- NeedsSystem: Sims-like needs (safety, hunger, social, shelter, boredom)
  that decay over time and drive behavior priorities
- GoalManager: Long-term goals broken into steps (gather_wood,
  explore_area, find_food, check_container, go_home)
- SpatialMemory: Remembers locations of containers, crafting tables,
  interesting blocks, and home position
- DailyRoutine: Morning/day/evening/night phases with trait-influenced
  activity selection
- Brain now WAITS for tasks to complete instead of piling on new ones
- Goal-based decision making replaces random wander

OLLAMA:
- Pre-warm model on deploy (loads into GPU before first chat)
- Keep-alive pings every 2 minutes (prevents model unload)
- Adaptive timeouts: 60s cold, 15s warm, 90s retry
- Auto-retry on timeout failure

CRAFTING:
- Quantity parsing ("craft 5 sticks", "craft a wooden pickaxe")
- Number words supported ("craft three planks")
- Smarter item name extraction with preposition stopping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 16:07:23 -05:00
parent 7199acf149
commit 195ef2d860
9 changed files with 2348 additions and 592 deletions

View file

@ -71,6 +71,13 @@ export default function inject(bot: BedrockBot) {
return best; return best;
}; };
// Helper to convert held item to wire format for packets
function heldItemToNotch() {
const item = bot.heldItem;
if (!item) return { network_id: 0 };
return Item.toNotch(item, 0);
}
// Reset list of players and entities on login // Reset list of players and entities on login
bot._client.on('start_game', (packet) => { bot._client.on('start_game', (packet) => {
bot.players = {}; bot.players = {};
@ -523,16 +530,22 @@ export default function inject(bot: BedrockBot) {
} }
function itemUseOnEntity(target: any, type: number) { function itemUseOnEntity(target: any, type: number) {
const typeStr = ['attack', 'interact'][type]; const typeStr = ['attack', 'interact'][type];
// Provide empty item if hand is empty to avoid serialization crash // Convert held item to wire format using prismarine-item serialization
const heldItem = bot.heldItem || { const heldItemNotch = heldItemToNotch();
network_id: 0, // Player position must include eye height (matching player_auth_input format)
count: 0, const playerPos = {
metadata: 0, x: bot.entity.position.x,
has_stack_id: false, y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
stack_id: 0, z: bot.entity.position.z,
block_runtime_id: 0,
extra: { has_nbt: false, can_place_on: [], can_destroy: [] },
}; };
// Click position relative to the target entity
const clickPos = target.position
? {
x: target.position.x - bot.entity.position.x,
y: (target.position.y + (target.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
z: target.position.z - bot.entity.position.z,
}
: { x: 0, y: 0, z: 0 };
const transaction = { const transaction = {
transaction: { transaction: {
legacy: { legacy: {
@ -543,33 +556,27 @@ export default function inject(bot: BedrockBot) {
transaction_data: { transaction_data: {
entity_runtime_id: target.id, entity_runtime_id: target.id,
action_type: typeStr, action_type: typeStr,
hotbar_slot: bot.quickBarSlot, hotbar_slot: bot.quickBarSlot ?? 0,
held_item: heldItem, held_item: heldItemNotch,
player_pos: bot.entity.position, player_pos: playerPos,
click_pos: { click_pos: clickPos,
x: 0,
y: 0,
z: 0,
},
}, },
}, },
}; };
bot._client.write('inventory_transaction', transaction); bot._client.write('inventory_transaction', transaction);
} }
function attack(target: any, swing = true) { async function attack(target: any, swing = true) {
// arm animation comes before the use_entity packet on 1.8 // Look at the target entity before attacking (server validates player aim)
if (bot.supportFeature('armAnimationBeforeUse')) { if (target.position) {
if (swing) { const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0);
bot.swingArm(); // in inventory await bot.lookAt(targetEyePos, true);
}
attackEntity(target);
} else {
attackEntity(target);
if (swing) {
bot.swingArm(); // in inventory
}
} }
// On Bedrock, swing arm first then send the attack transaction
if (swing) {
bot.swingArm();
}
attackEntity(target);
} }
function fetchEntity(id: any) { function fetchEntity(id: any) {

View file

@ -253,6 +253,189 @@ bot.on('path_update', (results) => {
} }
}); });
// --- Player-friendly name → Bedrock item ID mapping ---
const ITEM_ALIASES = {
// Plural → singular
'sticks': 'stick',
'planks': 'planks',
'torches': 'torch',
'logs': 'oak_log',
'stones': 'stone',
'diamonds': 'diamond',
'emeralds': 'emerald',
'arrows': 'arrow',
'feathers': 'feather',
'strings': 'string',
'bones': 'bone',
'books': 'book',
'papers': 'paper',
'leathers': 'leather',
'bricks': 'brick',
'bowls': 'bowl',
// Common aliases
'wood': 'oak_log',
'wood_planks': 'planks',
'oak_plank': 'oak_planks',
'wooden_plank': 'oak_planks',
'wooden_planks': 'oak_planks',
'plank': 'planks',
'log': 'oak_log',
'oak_wood': 'oak_log',
'cobble': 'cobblestone',
'stone_brick': 'stonebrick',
// Tool aliases — "wood_" vs "wooden_"
'wood_pickaxe': 'wooden_pickaxe',
'wood_sword': 'wooden_sword',
'wood_axe': 'wooden_axe',
'wood_shovel': 'wooden_shovel',
'wood_hoe': 'wooden_hoe',
// Crafting stations
'workbench': 'crafting_table',
'bench': 'crafting_table',
'craft_table': 'crafting_table',
// Misc
'iron': 'iron_ingot',
'gold': 'gold_ingot',
'redstone_lamp': 'redstone_lamp',
'furnaces': 'furnace',
'chests': 'chest',
'bed': 'bed',
'beds': 'bed',
'boat': 'oak_boat',
'bucket': 'bucket',
'shears': 'shears',
'compass': 'compass',
'clock': 'clock',
'map': 'empty_map',
};
// Known crafting recipes with human-readable ingredient names (for error messages)
const RECIPE_INGREDIENTS = {
'stick': { materials: { 'planks (any)': 2 }, yields: 4, needsTable: false },
'crafting_table': { materials: { 'planks (any)': 4 }, yields: 1, needsTable: false },
'oak_planks': { materials: { 'oak_log': 1 }, yields: 4, needsTable: false },
'spruce_planks': { materials: { 'spruce_log': 1 }, yields: 4, needsTable: false },
'birch_planks': { materials: { 'birch_log': 1 }, yields: 4, needsTable: false },
'wooden_pickaxe': { materials: { 'planks (any)': 3, 'stick': 2 }, yields: 1, needsTable: true },
'wooden_sword': { materials: { 'planks (any)': 2, 'stick': 1 }, yields: 1, needsTable: true },
'wooden_axe': { materials: { 'planks (any)': 3, 'stick': 2 }, yields: 1, needsTable: true },
'wooden_shovel': { materials: { 'planks (any)': 1, 'stick': 2 }, yields: 1, needsTable: true },
'wooden_hoe': { materials: { 'planks (any)': 2, 'stick': 2 }, yields: 1, needsTable: true },
'stone_pickaxe': { materials: { 'cobblestone': 3, 'stick': 2 }, yields: 1, needsTable: true },
'stone_sword': { materials: { 'cobblestone': 2, 'stick': 1 }, yields: 1, needsTable: true },
'stone_axe': { materials: { 'cobblestone': 3, 'stick': 2 }, yields: 1, needsTable: true },
'stone_shovel': { materials: { 'cobblestone': 1, 'stick': 2 }, yields: 1, needsTable: true },
'iron_pickaxe': { materials: { 'iron_ingot': 3, 'stick': 2 }, yields: 1, needsTable: true },
'iron_sword': { materials: { 'iron_ingot': 2, 'stick': 1 }, yields: 1, needsTable: true },
'iron_axe': { materials: { 'iron_ingot': 3, 'stick': 2 }, yields: 1, needsTable: true },
'diamond_pickaxe': { materials: { 'diamond': 3, 'stick': 2 }, yields: 1, needsTable: true },
'diamond_sword': { materials: { 'diamond': 2, 'stick': 1 }, yields: 1, needsTable: true },
'torch': { materials: { 'coal or charcoal': 1, 'stick': 1 }, yields: 4, needsTable: false },
'furnace': { materials: { 'cobblestone': 8 }, yields: 1, needsTable: true },
'chest': { materials: { 'planks (any)': 8 }, yields: 1, needsTable: true },
'bed': { materials: { 'planks (any)': 3, 'wool (any)': 3 }, yields: 1, needsTable: true },
'bucket': { materials: { 'iron_ingot': 3 }, yields: 1, needsTable: true },
'shears': { materials: { 'iron_ingot': 2 }, yields: 1, needsTable: false },
'bow': { materials: { 'stick': 3, 'string': 3 }, yields: 1, needsTable: true },
'arrow': { materials: { 'flint': 1, 'stick': 1, 'feather': 1 }, yields: 4, needsTable: true },
'paper': { materials: { 'sugar_cane': 3 }, yields: 3, needsTable: true },
'book': { materials: { 'paper': 3, 'leather': 1 }, yields: 1, needsTable: false },
'bookshelf': { materials: { 'planks (any)': 6, 'book': 3 }, yields: 1, needsTable: true },
};
/**
* Resolve a player-friendly item name to the actual Bedrock item ID.
* Returns { id, name } or null.
*/
function resolveItemName(rawName, mcData) {
// Normalize: lowercase, trim, replace spaces with underscores
let name = rawName.toLowerCase().trim().replace(/\s+/g, '_');
// Check alias map first
if (ITEM_ALIASES[name]) {
name = ITEM_ALIASES[name];
}
// Direct lookup
let item = mcData.itemsByName[name];
if (item) return { id: item.id, name: item.name };
// Try removing 's' plural
if (name.endsWith('s') && !name.endsWith('ss')) {
item = mcData.itemsByName[name.slice(0, -1)];
if (item) return { id: item.id, name: item.name };
}
// Try wood_ ↔ wooden_ swap
if (name.startsWith('wooden_')) {
item = mcData.itemsByName[name.replace('wooden_', 'wood_')];
if (item) return { id: item.id, name: item.name };
}
if (name.startsWith('wood_')) {
item = mcData.itemsByName[name.replace('wood_', 'wooden_')];
if (item) return { id: item.id, name: item.name };
}
// Fuzzy: substring match (prefer exact substring)
for (const [n, data] of Object.entries(mcData.itemsByName)) {
if (n === name || n.includes(name) || name.includes(n)) {
return { id: data.id, name: data.name };
}
}
return null;
}
/**
* Check bot inventory for required crafting materials.
* Returns { have: {name: count}, missing: {name: count}, canCraft: boolean }
*/
function checkCraftingMaterials(bot, itemName, craftCount) {
const recipe = RECIPE_INGREDIENTS[itemName];
if (!recipe) return null; // Unknown recipe — let mineflayer handle it
const inventory = {};
for (const item of bot.inventory.items()) {
const n = item.name.replace('minecraft:', '');
inventory[n] = (inventory[n] || 0) + item.count;
}
const batches = Math.ceil(craftCount / recipe.yields);
const have = {};
const missing = {};
let canCraft = true;
for (const [material, perBatch] of Object.entries(recipe.materials)) {
const needed = perBatch * batches;
// "planks (any)" matches any planks variant
let available = 0;
if (material.includes('(any)')) {
const base = material.split(' ')[0]; // e.g. "planks"
for (const [invName, invCount] of Object.entries(inventory)) {
if (invName === base || invName.endsWith('_' + base) || invName.includes(base)) {
available += invCount;
}
}
} else if (material === 'coal or charcoal') {
available = (inventory['coal'] || 0) + (inventory['charcoal'] || 0);
} else {
available = inventory[material] || 0;
}
have[material] = Math.min(available, needed);
if (available < needed) {
missing[material] = needed - available;
canCraft = false;
}
}
return { have, missing, canCraft, needsTable: recipe.needsTable };
}
// --- Hostile mob list --- // --- Hostile mob list ---
const HOSTILE_MOBS = new Set([ const HOSTILE_MOBS = new Set([
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider', 'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider',
@ -634,95 +817,132 @@ async function handleAction(action, params = {}) {
case 'list_recipes': { case 'list_recipes': {
const { itemName } = params; const { itemName } = params;
const mcData = require('minecraft-data')(bot.version); const mcData = require('minecraft-data')(bot.version);
const item = mcData.itemsByName[itemName]; const resolved = resolveItemName(itemName, mcData);
if (!item) return { recipes: [], error: `Unknown item: ${itemName}` }; if (!resolved) return { recipes: [], error: `Unknown item: ${itemName}` };
const recipes = bot.recipesFor(item.id); const recipes = bot.recipesFor(resolved.id);
return { return {
recipes: recipes.map((r, i) => ({ recipes: recipes.map((r, i) => ({
index: i, index: i,
ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [], ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [],
})), })),
resolvedName: resolved.name,
}; };
} }
case 'craft_item': { case 'craft_item': {
const { itemName, count: craftCount } = params; const { itemName, count: craftCount } = params;
const mcData = require('minecraft-data')(bot.version); const mcData = require('minecraft-data')(bot.version);
const wantCount = craftCount || 1;
// Try to find item — handle both "wooden_pickaxe" and "planks" style names // Step 1: Resolve the item name to a real Bedrock item ID
let item = mcData.itemsByName[itemName]; const resolved = resolveItemName(itemName, mcData);
if (!item) { if (!resolved) {
// Try common Bedrock name variants
const variants = [
itemName,
itemName.replace('wooden_', 'wood_'),
itemName.replace('wood_', 'wooden_'),
`minecraft:${itemName}`,
itemName.replace('_', ''),
];
for (const v of variants) {
item = mcData.itemsByName[v];
if (item) break;
}
// Try fuzzy match
if (!item) {
for (const [name, data] of Object.entries(mcData.itemsByName)) {
if (name.includes(itemName) || itemName.includes(name)) {
item = data;
break;
}
}
}
}
if (!item) {
return { crafted: false, error: `I don't know what "${itemName.replace(/_/g, ' ')}" is.` }; return { crafted: false, error: `I don't know what "${itemName.replace(/_/g, ' ')}" is.` };
} }
const realName = resolved.name;
const friendlyName = realName.replace(/_/g, ' ');
log('client', 'INFO', `Craft request: "${itemName}" → resolved to "${realName}" (id=${resolved.id})`);
// Step 1: Find crafting table (search wider radius) // Step 2: Check inventory for required materials BEFORE doing anything
let craftingTable = bot.findBlock({ const materialCheck = checkCraftingMaterials(bot, realName, wantCount);
matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'), if (materialCheck && !materialCheck.canCraft) {
maxDistance: 32, const missingList = Object.entries(materialCheck.missing)
}); .map(([mat, qty]) => `${qty}x ${mat.replace(/_/g, ' ')}`)
.join(', ');
return {
crafted: false,
error: `I need ${missingList} to craft ${friendlyName}, but I don't have enough.`,
missing: materialCheck.missing,
have: materialCheck.have,
};
}
// Step 3: Determine if a crafting table is needed
const needsTable = materialCheck ? materialCheck.needsTable : true; // Assume table needed if unknown recipe
// Step 4: Find and walk to crafting table if needed
let craftingTable = null;
if (needsTable) {
craftingTable = bot.findBlock({
matching: (block) => {
const n = block.name.replace('minecraft:', '');
return n === 'crafting_table' || n === 'workbench';
},
maxDistance: 48,
});
if (!craftingTable) {
return {
crafted: false,
error: `I need a crafting table to make ${friendlyName}, but I can't find one nearby.`,
};
}
// Step 2: Walk to crafting table if found but not close enough
if (craftingTable) {
const dist = bot.entity.position.distanceTo(craftingTable.position); const dist = bot.entity.position.distanceTo(craftingTable.position);
if (dist > 4) { if (dist > 3.5) {
log('client', 'INFO', `Walking to crafting table at ${craftingTable.position}`); log('client', 'INFO', `Walking to crafting table at ${craftingTable.position} (dist=${dist.toFixed(1)})`);
bot.pathfinder.setGoal(new GoalNear( bot.pathfinder.setGoal(new GoalNear(
craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 2 craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 2
)); ));
// Wait for arrival (up to 15 seconds) // Wait for arrival (up to 20 seconds)
await new Promise((resolve) => { await new Promise((resolve) => {
const check = setInterval(() => { const check = setInterval(() => {
const d = bot.entity.position.distanceTo(craftingTable.position); const d = bot.entity.position.distanceTo(craftingTable.position);
if (d <= 3) { clearInterval(check); resolve(); } if (d <= 3.5) { clearInterval(check); resolve(); }
}, 500); }, 400);
setTimeout(() => { clearInterval(check); resolve(); }, 15000); setTimeout(() => { clearInterval(check); resolve(); }, 20000);
});
// Re-find the table now that we're close
craftingTable = bot.findBlock({
matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'),
maxDistance: 4,
}); });
} }
// Re-find the table now that we're close (block reference may have changed)
craftingTable = bot.findBlock({
matching: (block) => {
const n = block.name.replace('minecraft:', '');
return n === 'crafting_table' || n === 'workbench';
},
maxDistance: 5,
});
if (!craftingTable) {
return {
crafted: false,
error: `I walked to the crafting table but can't reach it. Is it blocked?`,
};
}
} }
// Step 3: Check recipes // Step 5: Look up recipes from mineflayer
const recipes = bot.recipesFor(item.id, null, null, craftingTable || undefined); const recipes = bot.recipesFor(resolved.id, null, null, craftingTable || undefined);
if (recipes.length === 0) { if (recipes.length === 0) {
const reason = craftingTable // Try without table in case mineflayer knows it as a 2x2 recipe
? `I don't have the materials to craft ${itemName.replace(/_/g, ' ')}.` const recipes2x2 = bot.recipesFor(resolved.id, null, null, undefined);
: `I need a crafting table to make ${itemName.replace(/_/g, ' ')}.`; if (recipes2x2.length > 0) {
return { crafted: false, error: reason }; // It's a 2x2 recipe, no table needed after all
try {
await bot.craft(recipes2x2[0], wantCount, undefined);
log('client', 'INFO', `Crafted ${wantCount}x ${realName} (2x2, no table)`);
return { crafted: true, item: realName, count: wantCount };
} catch (e) {
return { crafted: false, error: `Crafting failed: ${e.message}` };
}
}
// Build a helpful error with inventory contents
const invItems = bot.inventory.items().map(i => `${i.count}x ${i.name.replace('minecraft:', '')}`);
const invStr = invItems.length > 0 ? invItems.join(', ') : 'nothing';
return {
crafted: false,
error: `I can't craft ${friendlyName} right now. My inventory has: ${invStr}.`,
};
} }
// Step 4: Craft! // Step 6: Craft!
try { try {
await bot.craft(recipes[0], craftCount || 1, craftingTable || undefined); await bot.craft(recipes[0], wantCount, craftingTable || undefined);
log('client', 'INFO', `Crafted ${craftCount || 1}x ${itemName}`); log('client', 'INFO', `Crafted ${wantCount}x ${realName}`);
return { crafted: true, item: itemName, count: craftCount || 1 }; return { crafted: true, item: realName, count: wantCount };
} catch (e) { } catch (e) {
log('client', 'ERROR', `Craft failed: ${e.message}`);
return { crafted: false, error: `Crafting failed: ${e.message}` }; return { crafted: false, error: `Crafting failed: ${e.message}` };
} }
} }

View file

@ -3,8 +3,10 @@ Ollama API client.
Handles communication with the local Ollama server for AI reasoning. Handles communication with the local Ollama server for AI reasoning.
""" """
import time
import threading
import httpx import httpx
from typing import Optional, AsyncGenerator from typing import Optional
from dougbot.utils.logging import get_logger from dougbot.utils.logging import get_logger
log = get_logger("ai.ollama") log = get_logger("ai.ollama")
@ -13,10 +15,29 @@ log = get_logger("ai.ollama")
class OllamaClient: class OllamaClient:
"""HTTP client for the Ollama REST API.""" """HTTP client for the Ollama REST API."""
# Timeout for first request (model may need to load into GPU)
COLD_TIMEOUT = 60.0
# Timeout for subsequent requests (model already warm)
WARM_TIMEOUT = 15.0
# Retry timeout (longer, used after a failed attempt)
RETRY_TIMEOUT = 90.0
# Keep-alive interval in seconds
KEEPALIVE_INTERVAL = 120.0
def __init__(self, base_url: str = "http://127.0.0.1:11434"): def __init__(self, base_url: str = "http://127.0.0.1:11434"):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
# Long timeout — first request loads the model into GPU/RAM which can take minutes self._model_warm = False
self._client = httpx.Client(timeout=300.0) self._closed = False
self._keepalive_timer: Optional[threading.Timer] = None
self._keepalive_model: Optional[str] = None
# Use a shared client with the cold timeout initially
self._client = httpx.Client(timeout=self.COLD_TIMEOUT)
def _get_timeout(self) -> float:
"""Return the appropriate timeout based on model warmth."""
if self._model_warm:
return self.WARM_TIMEOUT
return self.COLD_TIMEOUT
def list_models(self) -> list[str]: def list_models(self) -> list[str]:
""" """
@ -37,6 +58,103 @@ class OllamaClient:
log.error(f"Failed to list models: {e}") log.error(f"Failed to list models: {e}")
return [] return []
def warm_model(self, model: str) -> bool:
"""
Pre-warm a model by sending a tiny request to load it into GPU memory.
Should be called in a background thread. Returns True on success.
"""
log.info(f"Pre-warming model '{model}'...")
t0 = time.time()
try:
resp = self._client.post(
f"{self.base_url}/api/chat",
json={
"model": model,
"messages": [{"role": "user", "content": "hello"}],
"stream": False,
"think": False,
"options": {"num_predict": 1},
},
timeout=self.COLD_TIMEOUT,
)
resp.raise_for_status()
elapsed = time.time() - t0
self._model_warm = True
log.info(f"Model '{model}' warmed in {elapsed:.1f}s")
return True
except Exception as e:
elapsed = time.time() - t0
log.error(f"Failed to warm model '{model}' after {elapsed:.1f}s: {e}")
return False
def warm_model_async(self, model: str, callback=None):
"""
Pre-warm the model in a background thread.
Args:
model: Model name to warm up.
callback: Optional callable(success: bool) invoked when done.
"""
def _run():
success = self.warm_model(model)
if callback:
callback(success)
thread = threading.Thread(target=_run, daemon=True, name="ollama-warmup")
thread.start()
def start_keepalive(self, model: str):
"""
Start a recurring keep-alive ping that sends a tiny request every
KEEPALIVE_INTERVAL seconds to prevent the model from being unloaded.
"""
self._keepalive_model = model
self._schedule_keepalive()
log.info(f"Keep-alive started for '{model}' (every {self.KEEPALIVE_INTERVAL:.0f}s)")
def _schedule_keepalive(self):
"""Schedule the next keep-alive ping."""
if self._closed:
return
self._keepalive_timer = threading.Timer(
self.KEEPALIVE_INTERVAL, self._keepalive_ping
)
self._keepalive_timer.daemon = True
self._keepalive_timer.start()
def _keepalive_ping(self):
"""Send a tiny request to keep the model loaded in GPU memory."""
if self._closed or not self._keepalive_model:
return
try:
resp = self._client.post(
f"{self.base_url}/api/chat",
json={
"model": self._keepalive_model,
"messages": [{"role": "user", "content": "hi"}],
"stream": False,
"think": False,
"options": {"num_predict": 1},
},
timeout=self.COLD_TIMEOUT,
)
resp.raise_for_status()
self._model_warm = True
log.debug("Keep-alive ping OK")
except Exception as e:
self._model_warm = False
log.warning(f"Keep-alive ping failed: {e}")
# Schedule the next ping
self._schedule_keepalive()
def stop_keepalive(self):
"""Stop the keep-alive timer."""
if self._keepalive_timer:
self._keepalive_timer.cancel()
self._keepalive_timer = None
self._keepalive_model = None
def chat( def chat(
self, self,
model: str, model: str,
@ -44,23 +162,25 @@ class OllamaClient:
user_message: str, user_message: str,
chat_history: Optional[list[dict]] = None, chat_history: Optional[list[dict]] = None,
temperature: float = 0.8, temperature: float = 0.8,
num_predict: int = 150,
) -> Optional[str]: ) -> Optional[str]:
""" """
Send a chat request to Ollama and return the response. Send a chat request to Ollama and return the response.
Includes automatic retry with longer timeout on failure.
Args: Args:
model: Model name (e.g., "llama3", "mistral") model: Model name (e.g., "llama3.2:3b", "qwen3.5:9b")
system_prompt: The system prompt (persona + context) system_prompt: The system prompt (persona + context)
user_message: The user's message user_message: The user's message
chat_history: Previous messages for context chat_history: Previous messages for context
temperature: Creativity (0.0-1.0, higher = more creative) temperature: Creativity (0.0-1.0, higher = more creative)
num_predict: Max tokens to generate
Returns: Returns:
The AI response text, or None on failure. The AI response text, or None on failure.
""" """
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
# Add conversation history
if chat_history: if chat_history:
for msg in chat_history: for msg in chat_history:
messages.append({ messages.append({
@ -68,48 +188,75 @@ class OllamaClient:
"content": msg.get("content", ""), "content": msg.get("content", ""),
}) })
# Add current message
messages.append({"role": "user", "content": user_message}) messages.append({"role": "user", "content": user_message})
payload = {
"model": model,
"messages": messages,
"stream": False,
"think": False,
"options": {
"temperature": temperature,
"num_predict": num_predict,
},
}
# First attempt
timeout = self._get_timeout()
reply = self._do_chat_request(payload, timeout)
if reply is not None:
self._model_warm = True
return reply
# Retry once with a longer timeout
log.info(f"Retrying Ollama request with {self.RETRY_TIMEOUT}s timeout...")
self._model_warm = False
reply = self._do_chat_request(payload, self.RETRY_TIMEOUT)
if reply is not None:
self._model_warm = True
return reply
log.error("Ollama request failed after retry")
return None
def _do_chat_request(self, payload: dict, timeout: float) -> Optional[str]:
"""Execute a single chat request. Returns reply text or None."""
t0 = time.time()
try: try:
response = self._client.post( response = self._client.post(
f"{self.base_url}/api/chat", f"{self.base_url}/api/chat",
json={ json=payload,
"model": model, timeout=timeout,
"messages": messages,
"stream": False,
"think": False, # Disable thinking mode (Qwen3.5, etc.)
"options": {
"temperature": temperature,
"num_predict": 150, # Cap response length for chat
},
},
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
reply = data.get("message", {}).get("content", "").strip()
reply = data.get("message", {}).get("content", "") elapsed = time.time() - t0
log.debug(f"Ollama response ({len(reply)} chars): {reply[:100]}...") log.debug(f"Ollama response in {elapsed:.1f}s ({len(reply)} chars): {reply[:100]}")
return reply return reply if reply else None
except httpx.ConnectError: except httpx.ConnectError:
log.error(f"Cannot connect to Ollama at {self.base_url}") log.error(f"Cannot connect to Ollama at {self.base_url}")
return None return None
except httpx.TimeoutException: except httpx.TimeoutException:
log.error("Ollama request timed out") elapsed = time.time() - t0
log.error(f"Ollama request timed out after {elapsed:.1f}s (limit={timeout}s)")
return None return None
except Exception as e: except Exception as e:
log.error(f"Ollama chat failed: {e}") elapsed = time.time() - t0
log.error(f"Ollama chat failed after {elapsed:.1f}s: {e}")
return None return None
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if the Ollama server is reachable.""" """Check if the Ollama server is reachable."""
try: try:
response = self._client.get(f"{self.base_url}/api/tags") response = self._client.get(f"{self.base_url}/api/tags", timeout=5.0)
return response.status_code == 200 return response.status_code == 200
except Exception: except Exception:
return False return False
def close(self) -> None: def close(self) -> None:
"""Close the HTTP client.""" """Close the HTTP client and stop background tasks."""
self._closed = True
self.stop_keepalive()
self._client.close() self._client.close()

View file

@ -1,6 +1,12 @@
""" """
Behavior modules for Doug. Each behavior generates tasks based on Behavior systems for Doug's goal-based brain.
world state and persona traits.
Modules:
- NeedsSystem: Sims-like needs (safety, hunger, social, shelter, boredom) 0-100
- GoalManager: Long-term goals broken into steps
- SpatialMemory: Remembers where things are
- DailyRoutine: Time-of-day activity phases
- BehaviorEngine: World state container (kept for compatibility)
""" """
import math import math
@ -14,11 +20,551 @@ from dougbot.utils.logging import get_logger
log = get_logger("core.behaviors") log = get_logger("core.behaviors")
# ═══════════════════════════════════════════════════════════════════
# NEEDS SYSTEM — like The Sims, values 0-100, decay over time
# ═══════════════════════════════════════════════════════════════════
class NeedsSystem:
"""
Tracks Doug's needs. Each need is 0-100.
Low values = urgent need. High values = satisfied.
Needs decay every brain tick (2s) based on conditions.
"""
def __init__(self, traits: dict):
self._traits = traits
# All needs start satisfied
self.safety = 100 # Threat level (low = danger nearby)
self.hunger = 80 # Food level (low = need to eat)
self.social = 60 # Interaction need (low = lonely)
self.shelter = 70 # Has safe place (low = exposed)
self.boredom = 60 # Stimulation (low = bored)
def decay(self, health: int, food: int, has_shelter: bool,
is_night: bool, has_players_nearby: bool, hostiles_nearby: int):
"""Called every brain tick (2s). Decay needs based on conditions."""
# SAFETY: based on health and threats
if hostiles_nearby > 0:
self.safety = max(0, self.safety - 8 * hostiles_nearby)
elif health < 10:
self.safety = max(0, min(self.safety, health * 5))
else:
# Slowly recover when safe
self.safety = min(100, self.safety + 3)
# HUNGER: directly tied to food bar (food is 0-20 in MC)
self.hunger = min(100, max(0, food * 5))
# SOCIAL: decays when alone, recovers near players
sociability = self._traits.get("sociability", 50)
if has_players_nearby:
self.social = min(100, self.social + 2)
else:
# Higher sociability = faster social decay (needs people more)
decay_rate = 0.3 + (sociability / 200) # 0.3 to 0.55 per tick
self.social = max(0, self.social - decay_rate)
# SHELTER: drops at night without shelter, fine during day
if is_night and not has_shelter:
anxiety = self._traits.get("anxiety", 0)
self.shelter = max(0, self.shelter - (2 + (1 if anxiety else 0)))
elif has_shelter:
self.shelter = min(100, self.shelter + 5)
else:
# Daytime without shelter is fine
self.shelter = min(100, self.shelter + 1)
# BOREDOM: always slowly decays, faster if nothing is happening
curiosity = self._traits.get("curiosity", 50)
decay_rate = 0.5 + (curiosity / 200) # Curious = bored faster
self.boredom = max(0, self.boredom - decay_rate)
def on_health_change(self, health: int, food: int):
"""Immediate update when health changes."""
if health < 6:
self.safety = min(self.safety, 10)
self.hunger = min(100, max(0, food * 5))
def on_death(self):
"""Reset after death."""
self.safety = 50
self.hunger = 80
self.social = 60
self.shelter = 30
self.boredom = 60
def on_player_nearby(self):
"""Boost social when a player appears."""
self.social = min(100, self.social + 15)
def on_scan(self, hostiles_nearby: int, players_nearby: int):
"""Update from scan results."""
if hostiles_nearby == 0:
self.safety = min(100, self.safety + 5)
if players_nearby > 0:
self.social = min(100, self.social + 3)
def get_critical_needs(self) -> list[str]:
"""Get list of critically low needs for context."""
critical = []
if self.safety < 25:
critical.append("unsafe")
if self.hunger < 25:
critical.append("hungry")
if self.social < 20:
critical.append("lonely")
if self.shelter < 20:
critical.append("exposed")
if self.boredom < 15:
critical.append("bored")
return critical
def most_urgent_need(self) -> tuple[str, int]:
"""Return the name and value of the most urgent (lowest) need."""
needs = {
"safety": self.safety,
"hunger": self.hunger,
"social": self.social,
"shelter": self.shelter,
"boredom": self.boredom,
}
lowest = min(needs, key=needs.get)
return lowest, needs[lowest]
# ═══════════════════════════════════════════════════════════════════
# GOAL MANAGER — long-term goals broken into steps
# ═══════════════════════════════════════════════════════════════════
class GoalManager:
"""
Manages Doug's long-term goals. Each goal is a dict:
{
"name": "gather_wood",
"description": "Gather wood for building",
"priority": 5, # 1-10
"steps": [...], # List of step dicts
"current_step": 0,
"created_at": time,
"status": "active" | "complete" | "failed"
}
"""
# Goal templates — what Doug knows how to do
GOAL_TEMPLATES = {
"gather_wood": {
"description": "Gather some wood",
"priority": 5,
"steps": [
{"action": "find_blocks", "params": {"blockName": "oak_log", "radius": 32, "count": 1},
"description": "Finding trees"},
{"action": "dig_block", "params": {}, # position filled from find result
"description": "Chopping wood"},
],
},
"explore_area": {
"description": "Explore the surroundings",
"priority": 3,
"steps": [
{"action": "move_to", "params": {"range": 2},
"description": "Exploring a new area"},
],
},
"find_food": {
"description": "Find something to eat",
"priority": 7,
"steps": [
{"action": "find_blocks",
"params": {"blockName": "wheat", "radius": 32, "count": 1},
"description": "Looking for food sources"},
],
},
"check_container": {
"description": "Check a nearby container",
"priority": 4,
"steps": [
{"action": "move_to", "params": {"range": 2},
"description": "Going to the container"},
{"action": "open_chest", "params": {},
"description": "Opening the container"},
],
},
"visit_interesting": {
"description": "Check out something interesting",
"priority": 3,
"steps": [
{"action": "move_to", "params": {"range": 2},
"description": "Going to check it out"},
],
},
"go_home": {
"description": "Head back home",
"priority": 6,
"steps": [
{"action": "move_to", "params": {"range": 3},
"description": "Walking home"},
],
},
}
def __init__(self, traits: dict, age: int):
self._traits = traits
self._age = age
self._goals: list[dict] = []
self._completed_goals: list[str] = [] # names of recently completed goals
self._max_goals = 5 # Don't pile up too many goals
def has_any_goals(self) -> bool:
return any(g["status"] == "active" for g in self._goals)
def has_goal(self, name: str) -> bool:
return any(g["name"] == name and g["status"] == "active" for g in self._goals)
def add_goal(self, name: str, priority: int = 5, target_pos: dict = None,
extra_params: dict = None):
"""Add a goal from templates."""
if self.has_goal(name):
return
if len([g for g in self._goals if g["status"] == "active"]) >= self._max_goals:
# Remove lowest priority active goal
active = [g for g in self._goals if g["status"] == "active"]
active.sort(key=lambda g: g["priority"])
if active and active[0]["priority"] < priority:
active[0]["status"] = "dropped"
else:
return
template = self.GOAL_TEMPLATES.get(name)
if not template:
log.debug(f"Unknown goal template: {name}")
return
import copy
goal = {
"name": name,
"description": template["description"],
"priority": priority,
"steps": copy.deepcopy(template["steps"]),
"current_step": 0,
"created_at": time.time(),
"status": "active",
"target_pos": target_pos,
"extra_params": extra_params or {},
}
# Fill in target position for movement steps
if target_pos:
for step in goal["steps"]:
if step["action"] == "move_to":
step["params"].update(target_pos)
elif step["action"] == "open_chest":
step["params"].update(target_pos)
self._goals.append(goal)
log.info(f"New goal: {goal['description']} (priority {priority})")
def get_active_goal(self) -> dict | None:
"""Get the highest priority active goal."""
active = [g for g in self._goals if g["status"] == "active"]
if not active:
return None
active.sort(key=lambda g: g["priority"], reverse=True)
return active[0]
def get_next_step(self, goal: dict, behaviors, memory) -> Task | None:
"""Get the next task from a goal's step list."""
if goal["current_step"] >= len(goal["steps"]):
return None # All steps done
step = goal["steps"][goal["current_step"]]
goal["current_step"] += 1
# Build the task from the step
params = dict(step["params"])
params.update(goal.get("extra_params", {}))
# For find_blocks, we need a callback to process the result
if step["action"] == "find_blocks":
return Task(
name=f"goal_{goal['name']}_find",
priority=Priority.NORMAL,
action=step["action"],
params=params,
description=step["description"],
timeout=15,
)
return Task(
name=f"goal_{goal['name']}_step{goal['current_step']}",
priority=Priority.NORMAL,
action=step["action"],
params=params,
description=step["description"],
timeout=20,
)
def complete_goal(self, name: str):
"""Mark a goal as complete."""
for g in self._goals:
if g["name"] == name and g["status"] == "active":
g["status"] = "complete"
self._completed_goals.append(name)
if len(self._completed_goals) > 20:
self._completed_goals.pop(0)
log.info(f"Goal complete: {g['description']}")
break
# Clean up old goals
self._goals = [g for g in self._goals
if g["status"] == "active"
or (time.time() - g["created_at"]) < 300]
def on_death(self):
"""Clear all goals on death."""
for g in self._goals:
if g["status"] == "active":
g["status"] = "failed"
self._goals.clear()
def seed_initial_goals(self, memory, behaviors):
"""Create starting goals based on persona traits."""
curiosity = self._traits.get("curiosity", 50)
# Everyone starts by exploring their surroundings
self.add_goal("explore_area", priority=3,
target_pos=self._random_nearby_pos(behaviors.position, 15))
# Curious Dougs want to explore more
if curiosity > 60:
self.add_goal("explore_area", priority=4,
target_pos=self._random_nearby_pos(behaviors.position, 25))
# If containers are visible, check them
if behaviors.nearby_containers:
container = behaviors.nearby_containers[0]
self.add_goal("check_container", priority=4,
target_pos=container["position"])
def generate_goal_from_environment(self, memory, behaviors):
"""Generate a new goal based on what we know about the world."""
# Check memory for interesting things
containers = memory.get_known("container")
if containers and not self.has_goal("check_container"):
# Visit nearest unvisited container
nearest = min(containers, key=lambda c: _dist(behaviors.position, c))
self.add_goal("check_container", priority=4, target_pos=nearest)
return
crafting_tables = memory.get_known("crafting_table")
if crafting_tables and not self.has_goal("visit_interesting"):
nearest = min(crafting_tables, key=lambda c: _dist(behaviors.position, c))
self.add_goal("visit_interesting", priority=3, target_pos=nearest)
return
# Default: gather wood (always useful)
if not self.has_goal("gather_wood") and random.random() < 0.3:
self.add_goal("gather_wood", priority=4)
return
# Explore somewhere new
if not self.has_goal("explore_area"):
self.add_goal("explore_area", priority=2,
target_pos=self._random_nearby_pos(behaviors.position, 20))
def _random_nearby_pos(self, pos: dict, radius: float) -> dict:
angle = random.uniform(0, 2 * math.pi)
dist = random.uniform(radius * 0.5, radius)
return {
"x": pos["x"] + math.cos(angle) * dist,
"y": pos["y"],
"z": pos["z"] + math.sin(angle) * dist,
}
# ═══════════════════════════════════════════════════════════════════
# SPATIAL MEMORY — remembers where things are
# ═══════════════════════════════════════════════════════════════════
class SpatialMemory:
"""
Remembers locations Doug has seen and been to.
Stores positions of interesting things: containers, crafting tables,
players, hostile spawn areas, etc.
"""
def __init__(self):
self.home: dict | None = None # Home base position
self._known_locations: list[dict] = [] # {type, x, y, z, seen_at}
self._explored_chunks: set[tuple[int, int]] = set() # (chunk_x, chunk_z)
self._visited: list[dict] = [] # positions we've been to
self._max_locations = 200
self._max_visited = 50
def set_home(self, pos: dict):
self.home = {"x": pos["x"], "y": pos["y"], "z": pos["z"]}
def update_from_scan(self, position: dict, blocks: dict,
containers: list, players: list, hostiles: list):
"""Process scan results into memory."""
now = time.time()
# Record that we've been here
chunk = (int(position["x"]) // 16, int(position["z"]) // 16)
self._explored_chunks.add(chunk)
self._visited.append({"x": position["x"], "y": position["y"],
"z": position["z"], "time": now})
if len(self._visited) > self._max_visited:
self._visited.pop(0)
# Remember containers
for c in containers:
self._remember(c.get("type", "container"), c["position"], now)
# Remember interesting blocks
interesting_blocks = {
"crafting_table", "furnace", "chest", "ender_chest",
"enchanting_table", "anvil", "brewing_stand", "diamond_ore",
"iron_ore", "gold_ore", "coal_ore",
}
for block_type, positions in blocks.items():
if block_type in interesting_blocks:
for pos in positions[:3]: # Don't remember too many of one type
self._remember(block_type, pos, now)
# Remember where hostiles spawned (danger zones)
for h in hostiles:
if "position" in h:
self._remember(f"hostile_{h.get('type', 'mob')}", h["position"], now)
def _remember(self, loc_type: str, pos: dict, timestamp: float):
"""Add or update a location in memory."""
# Check if we already know about this spot (within 3 blocks)
for loc in self._known_locations:
if loc["type"] == loc_type:
dx = loc["x"] - pos.get("x", 0)
dz = loc["z"] - pos.get("z", 0)
if dx * dx + dz * dz < 9: # Within 3 blocks
loc["seen_at"] = timestamp
return
# New location
self._known_locations.append({
"type": loc_type,
"x": pos.get("x", 0),
"y": pos.get("y", 0),
"z": pos.get("z", 0),
"seen_at": timestamp,
"visited": False,
})
# Trim old locations if too many
if len(self._known_locations) > self._max_locations:
self._known_locations.sort(key=lambda l: l["seen_at"])
self._known_locations = self._known_locations[-self._max_locations:]
def get_known(self, loc_type: str) -> list[dict]:
"""Get all known locations of a given type."""
return [l for l in self._known_locations if l["type"] == loc_type]
def get_nearest_unexplored(self, pos: dict, max_dist: float = 50) -> dict | None:
"""Find nearest interesting unvisited location."""
unvisited = [l for l in self._known_locations
if not l.get("visited") and not l["type"].startswith("hostile_")]
if not unvisited:
return None
# Sort by distance
def dist(loc):
dx = pos["x"] - loc["x"]
dz = pos["z"] - loc["z"]
return math.sqrt(dx * dx + dz * dz)
unvisited.sort(key=dist)
nearest = unvisited[0]
d = dist(nearest)
if d > max_dist:
return None
# Mark as visited so we don't go back immediately
nearest["visited"] = True
return nearest
def suggest_explore_angle(self, pos: dict) -> float:
"""Suggest a direction to explore that we haven't visited much."""
# Check which directions we've explored
chunk_x = int(pos["x"]) // 16
chunk_z = int(pos["z"]) // 16
# Score each of 8 directions
best_angle = random.uniform(0, 2 * math.pi)
best_score = -1
for i in range(8):
angle = (2 * math.pi * i) / 8
# Check chunks in this direction
check_x = chunk_x + int(math.cos(angle) * 2)
check_z = chunk_z + int(math.sin(angle) * 2)
# Score: prefer unexplored chunks
score = 0
for dx in range(-1, 2):
for dz in range(-1, 2):
if (check_x + dx, check_z + dz) not in self._explored_chunks:
score += 1
# Add some randomness
score += random.uniform(0, 2)
if score > best_score:
best_score = score
best_angle = angle
return best_angle
# ═══════════════════════════════════════════════════════════════════
# DAILY ROUTINE — time-of-day phases
# ═══════════════════════════════════════════════════════════════════
class DailyRoutine:
"""
Maps Minecraft time-of-day to activity phases.
MC day ticks: 0=sunrise, 6000=noon, 12000=sunset, 18000=midnight
"""
def __init__(self, traits: dict, age: int):
self._traits = traits
self._age = age
def get_phase(self, day_time: int) -> str:
"""Get the current routine phase."""
# Normalize to 0-24000
t = day_time % 24000
if t < 1000:
return "morning" # 0-1000: sunrise, wake up
elif t < 11000:
return "day" # 1000-11000: main working hours
elif t < 12500:
return "evening" # 11000-12500: sunset, wind down
else:
return "night" # 12500-24000: nighttime
# ═══════════════════════════════════════════════════════════════════
# BEHAVIOR ENGINE — world state container (kept for compatibility)
# ═══════════════════════════════════════════════════════════════════
class BehaviorEngine: class BehaviorEngine:
"""Generates tasks based on Doug's state, surroundings, and personality.""" """
Holds Doug's world state. Updated by brain from scan results.
Kept for backward compatibility main_window accesses these fields.
"""
def __init__(self, traits: dict, age: int, doug_name: str): def __init__(self, traits: dict, age: int, doug_name: str):
self._traits = traits # Persona trait values self._traits = traits
self._age = age self._age = age
self._name = doug_name self._name = doug_name
@ -37,349 +583,17 @@ class BehaviorEngine:
self.inventory: list[dict] = [] self.inventory: list[dict] = []
self.spawn_pos = {"x": 0, "y": 0, "z": 0} self.spawn_pos = {"x": 0, "y": 0, "z": 0}
# Behavior state
self._last_scan_time = 0.0
self._last_chat_time = 0.0
self._last_wander_time = 0.0
self._last_combat_time = 0.0
self._explored_positions: list[dict] = [] # Places we've been
self._known_containers: list[dict] = [] # Containers we've found
self._relationships: dict[str, float] = {} # Player name → fondness (-1 to 1)
self._deaths_seen: list[dict] = []
# --- Trait helpers ---
def _trait(self, name: str, default: int = 50) -> int:
"""Get a trait value (0-100 slider) or bool quirk."""
return self._traits.get(name, default)
def _has_quirk(self, name: str) -> bool:
"""Check if a boolean quirk is enabled."""
return bool(self._traits.get(name, False))
def _trait_chance(self, trait_name: str, base: float = 0.5) -> bool:
"""Random check weighted by a trait. Higher trait = more likely."""
val = self._trait(trait_name, 50) / 100.0
return random.random() < (base * val)
# --- Core behavior generators ---
def get_survival_task(self) -> Optional[Task]:
"""Check for survival needs — health, food, immediate danger."""
# Critical health — flee from everything
if self.health <= 4:
hostile = self._nearest_hostile(12)
if hostile:
return self._flee_task(hostile, "Critical health!")
# Flee from close hostiles based on bravery
bravery = self._trait("bravery", 50)
flee_distance = max(4, 12 - bravery // 10) # Brave = smaller flee radius
flee_health_threshold = max(6, 18 - bravery // 8) # Brave = lower threshold
close_hostile = self._nearest_hostile(flee_distance)
if close_hostile and self.health < flee_health_threshold:
return self._flee_task(close_hostile, f"Fleeing from {close_hostile.get('type', 'mob')}")
# Anxiety quirk: flee from ANY hostile within 10 blocks regardless of health
if self._has_quirk("anxiety") and self._nearest_hostile(10):
hostile = self._nearest_hostile(10)
return self._flee_task(hostile, "Too scary!")
# Night fear (anxiety): seek shelter
if self._has_quirk("anxiety") and self.is_night and not self._is_near_shelter():
return Task(
name="seek_shelter",
priority=Priority.URGENT,
action="move_to",
params={**self.spawn_pos, "range": 3},
description="Running back to safety",
timeout=30,
)
# Eat if hungry and we have food
if self.food <= 8:
food_item = self._find_food_in_inventory()
if food_item:
return Task(
name="eat",
priority=Priority.URGENT,
action="equip_item",
params={"name": food_item, "destination": "hand"},
description=f"Eating {food_item}",
timeout=10,
)
return None
def get_social_task(self) -> Optional[Task]:
"""Social behaviors — interact with nearby players."""
if not self.nearby_players:
return None
sociability = self._trait("sociability", 50)
# Follow nearby player if sociable and they're far-ish
for player in self.nearby_players:
dist = player.get("distance", 99)
# Very social Doug follows players around
if sociability > 70 and dist > 6 and dist < 30:
if self._trait_chance("sociability", 0.3):
return Task(
name=f"follow_{player['name']}",
priority=Priority.LOW,
action="follow_player",
params={"name": player["name"], "range": 4},
description=f"Following {player['name']}",
timeout=20,
)
# Walk toward player if they're close enough to interact
if dist > 3 and dist < 15 and self._trait_chance("sociability", 0.15):
return Task(
name=f"approach_{player['name']}",
priority=Priority.LOW,
action="move_to",
params={**player["position"], "range": 3},
description=f"Walking toward {player['name']}",
timeout=15,
)
return None
def get_exploration_task(self) -> Optional[Task]:
"""Exploration and curiosity behaviors."""
curiosity = self._trait("curiosity", 50)
# Check signs nearby
if self.nearby_signs and self._trait_chance("curiosity", 0.5):
sign = self.nearby_signs[0]
sign_pos = sign["position"]
dist = self._distance_to_pos(sign_pos)
if dist > 2:
return Task(
name="read_sign",
priority=Priority.NORMAL,
action="move_to",
params={**sign_pos, "range": 2},
description=f"Going to read a sign",
timeout=15,
)
# Check containers nearby (OCD quirk = organize, curiosity = peek)
if self.nearby_containers:
for container in self.nearby_containers:
dist = self._distance_to_pos(container["position"])
if dist < 5:
if self._has_quirk("ocd") or self._trait_chance("curiosity", 0.2):
return Task(
name="check_container",
priority=Priority.NORMAL,
action="open_chest",
params=container["position"],
description=f"Checking a {container['type']}",
timeout=15,
callback="on_container_opened",
)
# Interesting blocks nearby
if self.nearby_blocks and curiosity > 40:
for block_type, positions in self.nearby_blocks.items():
if block_type == "crafting_table" and self._trait_chance("curiosity", 0.1):
pos = positions[0]
return Task(
name="visit_crafting_table",
priority=Priority.LOW,
action="move_to",
params={**pos, "range": 2},
description="Checking out a crafting table",
timeout=15,
)
# Wander/explore — higher curiosity = farther, more frequent
time_since_wander = time.time() - self._last_wander_time
wander_interval = max(4, 15 - curiosity // 8) # Curious = shorter interval
if time_since_wander > wander_interval:
self._last_wander_time = time.time()
return self._wander_task(curiosity)
return None
def get_combat_task(self) -> Optional[Task]:
"""Combat behaviors — attack hostiles based on bravery."""
bravery = self._trait("bravery", 50)
# Only attack if brave enough
if bravery < 30:
return None # Too scared to fight
# Cooldown — don't spam combat tasks
if time.time() - self._last_combat_time < 12:
return None
# Find attackable hostile within melee range
for hostile in self.nearby_hostiles:
dist = hostile.get("distance", 99)
if dist < 5 and self.health > 8:
# Brave Dougs attack, others might not
if bravery > 60 or (bravery > 40 and self.health > 14):
self._last_combat_time = time.time()
return Task(
name="combat",
priority=Priority.HIGH,
action="attack_nearest_hostile",
params={"range": 6},
description=f"Fighting a {hostile['type']}!",
timeout=15,
)
return None
def get_organization_task(self) -> Optional[Task]:
"""OCD/organization behaviors."""
if not self._has_quirk("ocd"):
return None
# If we have a messy inventory, organize it
if len(self.inventory) > 10 and random.random() < 0.05:
return Task(
name="organize_inventory",
priority=Priority.LOW,
action="status", # Placeholder — will be multi-step
description="Organizing my stuff",
timeout=20,
)
return None
def get_idle_task(self) -> Optional[Task]:
"""Idle behaviors — what Doug does when bored."""
# Look around randomly
if random.random() < 0.4:
return Task(
name="look_around",
priority=Priority.IDLE,
action="look_at",
params={
"x": self.position["x"] + random.uniform(-20, 20),
"y": self.position["y"] + random.uniform(-3, 5),
"z": self.position["z"] + random.uniform(-20, 20),
},
description="",
timeout=3,
)
# Chatty Cathy: say something unprompted
if self._has_quirk("chatty_cathy") and self.nearby_players:
time_since_chat = time.time() - self._last_chat_time
if time_since_chat > 30 and random.random() < 0.15:
self._last_chat_time = time.time()
return Task(
name="idle_chat",
priority=Priority.LOW,
action="status", # Brain will handle via AI
description="chatting",
timeout=10,
callback="on_idle_chat",
)
return None
# --- Task factories ---
def _flee_task(self, hostile: dict, reason: str) -> Task:
"""Create a flee task away from a hostile."""
hpos = hostile.get("position", self.position)
dx = self.position["x"] - hpos.get("x", 0)
dz = self.position["z"] - hpos.get("z", 0)
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
flee_dist = 12
flee_x = self.position["x"] + (dx / dist) * flee_dist
flee_z = self.position["z"] + (dz / dist) * flee_dist
return Task(
name="flee",
priority=Priority.URGENT,
action="move_to",
params={"x": flee_x, "y": self.position["y"], "z": flee_z, "range": 3},
description=reason,
timeout=15,
interruptible=False,
)
def _wander_task(self, curiosity: int) -> Task:
"""Create a wander task with distance based on curiosity."""
angle = random.uniform(0, 2 * math.pi)
dist = random.uniform(5, 8 + curiosity // 10) # Curious = farther
target_x = self.position["x"] + math.cos(angle) * dist
target_z = self.position["z"] + math.sin(angle) * dist
# Don't wander too far from spawn (radius based on curiosity)
max_radius = 30 + curiosity // 2 # Curious = wider range
dx = target_x - self.spawn_pos["x"]
dz = target_z - self.spawn_pos["z"]
if math.sqrt(dx * dx + dz * dz) > max_radius:
# Head back toward spawn
angle = math.atan2(
self.spawn_pos["z"] - self.position["z"],
self.spawn_pos["x"] - self.position["x"],
)
target_x = self.position["x"] + math.cos(angle) * 8
target_z = self.position["z"] + math.sin(angle) * 8
return Task(
name="wander",
priority=Priority.IDLE,
action="move_to",
params={"x": target_x, "y": self.position["y"], "z": target_z, "range": 2},
description="",
timeout=20,
)
# --- Helpers ---
def _nearest_hostile(self, max_dist: float) -> Optional[dict]:
"""Get nearest hostile within max_dist."""
closest = None
closest_dist = max_dist
for h in self.nearby_hostiles:
d = h.get("distance", 99)
if d < closest_dist:
closest = h
closest_dist = d
return closest
def _distance_to_pos(self, pos: dict) -> float:
dx = self.position["x"] - pos.get("x", 0)
dy = self.position["y"] - pos.get("y", 0)
dz = self.position["z"] - pos.get("z", 0)
return math.sqrt(dx * dx + dy * dy + dz * dz)
def _find_food_in_inventory(self) -> Optional[str]:
"""Find a food item in inventory."""
food_items = {
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
"golden_apple", "melon_slice", "sweet_berries", "baked_potato",
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie", "pumpkin_pie",
"cake", "dried_kelp", "carrot", "potato",
}
for item in self.inventory:
if item.get("name", "").replace("minecraft:", "") in food_items:
return item["name"]
return None
def _is_near_shelter(self) -> bool:
"""Check if Doug is near a sheltered area (has blocks above)."""
# Simplified: near spawn = near shelter
d = self._distance_to_pos(self.spawn_pos)
return d < 15
@property @property
def is_night(self) -> bool: def is_night(self) -> bool:
return self.day_time > 12000 return self.day_time > 12000
# ═══════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════
def _dist(pos_a: dict, pos_b: dict) -> float:
"""Distance between two position dicts."""
dx = pos_a.get("x", 0) - pos_b.get("x", 0)
dz = pos_a.get("z", 0) - pos_b.get("z", 0)
return math.sqrt(dx * dx + dz * dz)

View file

@ -1,27 +1,47 @@
""" """
Doug's Brain — the autonomous decision loop. Doug's Brain — goal-based autonomous decision engine.
Uses behavior engine + task queue for trait-driven decisions.
Ticks every 2 seconds: scan generate tasks execute top task. 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
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
""" """
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 TaskQueue, Task, Priority
from dougbot.core.behaviors import BehaviorEngine from dougbot.core.behaviors import (
NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine,
)
from dougbot.utils.logging import get_logger from dougbot.utils.logging import get_logger
log = get_logger("core.brain") log = get_logger("core.brain")
class DougBrain(QObject): class BrainState(IntEnum):
"""Autonomous decision engine with trait-driven behavior.""" """What the brain is doing right now."""
IDLE = 0
EXECUTING_TASK = 1
WAITING_FOR_SCAN = 2
# Signals
class DougBrain(QObject):
"""Goal-based autonomous decision engine with needs and memory."""
# Signals (same interface as before)
wants_to_chat = Signal(str) # Unprompted chat message wants_to_chat = Signal(str) # Unprompted chat message
wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say
@ -36,21 +56,33 @@ class DougBrain(QObject):
# Core systems # Core systems
self._tasks = TaskQueue() self._tasks = TaskQueue()
self._behaviors = BehaviorEngine(traits or {}, age, doug_name) 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)
# Scan state # Brain 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 = 3.0 # Seconds between full scans self._scan_interval = 4.0 # seconds between scans
# Action state # Tick counter for staggering updates
self._waiting_for_action = False self._tick_count = 0
self._action_sent_time = 0.0
# Chat throttle
self._last_chat_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)
log.info("Brain started — Doug is thinking") # 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)")
def stop(self): def stop(self):
self._running = False self._running = False
@ -59,6 +91,8 @@ class DougBrain(QObject):
self._ws.send_request("stop", {}) self._ws.send_request("stop", {})
log.info("Brain stopped") log.info("Brain stopped")
# ── Event handling (same interface) ──
def update_from_event(self, event: str, data: dict): def update_from_event(self, event: str, data: dict):
"""Update brain state from bridge events.""" """Update brain state from bridge events."""
if event == "spawn_complete": if event == "spawn_complete":
@ -67,30 +101,36 @@ class DougBrain(QObject):
"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0) "x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)
} }
self._behaviors.spawn_pos = dict(self._behaviors.position) self._behaviors.spawn_pos = dict(self._behaviors.position)
self._memory.set_home(self._behaviors.position)
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)
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._waiting_for_action = False self._state = BrainState.IDLE
self._tasks.complete() self._tasks.complete()
elif event == "movement_failed": elif event == "movement_failed":
self._waiting_for_action = False self._state = BrainState.IDLE
self._tasks.cancel() self._tasks.cancel()
elif event == "death": elif event == "death":
self._waiting_for_action = False self._state = BrainState.IDLE
self._tasks.clear() self._tasks.clear()
log.info("Doug died — clearing all tasks") self._needs.on_death()
self._goals.on_death()
log.info("Doug died — clearing all tasks and resetting needs")
elif event == "player_joined": elif event == "player_joined":
username = data.get("username", "") username = data.get("username", "")
if username and username != self._doug_name: if username and username != self._doug_name:
self._needs.on_player_nearby()
log.info(f"Player joined: {username}") log.info(f"Player joined: {username}")
elif event == "player_left": elif event == "player_left":
@ -98,41 +138,64 @@ class DougBrain(QObject):
if username: if username:
log.info(f"Player left: {username}") log.info(f"Player left: {username}")
# ── Main tick ──
def _tick(self): def _tick(self):
"""Main brain tick — scan, generate tasks, execute.""" """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
if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState: if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
return return
# Safety: unstick action timeout self._tick_count += 1
if self._waiting_for_action and (time.time() - self._action_sent_time > 20):
self._waiting_for_action = False # Safety: unstick action timeout (20s)
self._tasks.cancel() if self._state == BrainState.EXECUTING_TASK:
if time.time() - self._action_sent_time > 20:
log.debug("Action timed out — unsticking")
self._state = BrainState.IDLE
self._tasks.cancel()
else:
return # WAIT for current task to finish — don't pile on
# Safety: unstick pending scan # Safety: 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: Scan surroundings periodically # Step 1: Decay needs every tick (every 2s)
self._needs.decay(
health=self._behaviors.health,
food=self._behaviors.food,
has_shelter=self._is_sheltered(),
is_night=self._behaviors.is_night,
has_players_nearby=bool(self._behaviors.nearby_players),
hostiles_nearby=len(self._behaviors.nearby_hostiles),
)
# Step 2: Scan surroundings periodically
if not self._pending_scan and (time.time() - self._last_scan_time > self._scan_interval): if not self._pending_scan and (time.time() - self._last_scan_time > self._scan_interval):
self._pending_scan = True self._pending_scan = True
self._last_scan_time = time.time() self._last_scan_time = time.time()
self._ws.send_request("scan_surroundings", {"radius": 12}, 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 results before deciding return # Wait for scan before deciding
# Step 2: Generate tasks from behaviors (if not waiting for scan) if self._pending_scan:
if not self._pending_scan and not self._waiting_for_action: return # Still waiting for scan
self._generate_tasks()
# Step 3: Execute top task # Step 3: Decide what to do
if not self._waiting_for_action: task = self._decide()
self._execute_next_task() if task:
self._tasks.add(task)
# Step 4: Execute top task from queue
self._execute_next_task()
# ── Scanning & Memory ──
def _on_scan(self, response: ResponseMessage): def _on_scan(self, response: ResponseMessage):
"""Process surroundings scan.""" """Process scan results and update memory."""
self._pending_scan = False self._pending_scan = False
if response.status != "success": if response.status != "success":
return return
@ -148,54 +211,368 @@ class DougBrain(QObject):
self._behaviors.nearby_signs = data.get("signs", []) self._behaviors.nearby_signs = data.get("signs", [])
self._behaviors.nearby_blocks = data.get("blocks", {}) self._behaviors.nearby_blocks = data.get("blocks", {})
# Split entities into hostiles and others
entities = data.get("entities", []) entities = data.get("entities", [])
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(
position=self._behaviors.position,
blocks=self._behaviors.nearby_blocks,
containers=self._behaviors.nearby_containers,
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),
)
def _on_inventory(self, response: ResponseMessage): def _on_inventory(self, response: ResponseMessage):
"""Process inventory response."""
if response.status != "success": if response.status != "success":
return return
self._behaviors.inventory = response.data.get("items", []) self._behaviors.inventory = response.data.get("items", [])
def _generate_tasks(self): # ── Decision Engine ──
"""Ask behavior engine to generate tasks based on current state."""
# Priority order: survival → combat → social → exploration → organization → idle
generators = [
self._behaviors.get_survival_task,
self._behaviors.get_combat_task,
self._behaviors.get_social_task,
self._behaviors.get_exploration_task,
self._behaviors.get_organization_task,
self._behaviors.get_idle_task,
]
for gen in generators: def _decide(self) -> Task | None:
task = gen() """
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
# 1. CRITICAL SURVIVAL — safety need below 20
if self._needs.safety < 20:
task = self._survival_task()
if task: if task:
should_execute = self._tasks.add(task) return task
if should_execute:
break # High-priority task added, execute immediately # 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."""
b = self._behaviors
# Flee from nearby hostiles
hostile = self._nearest_hostile(12)
if hostile:
return self._flee_task(hostile)
# Critical health with no hostiles — stay still and 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,
)
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 = 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
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)
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
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",
timeout=30,
)
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
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']}",
timeout=20,
)
# 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
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): def _execute_next_task(self):
"""Execute the highest priority task.""" """Execute the highest priority task from the queue."""
task = self._tasks.next() task = self._tasks.next()
if not task: if not task:
return return
# Special callbacks # Handle special callbacks
if task.callback == "on_idle_chat": if task.callback == "on_idle_chat":
self._handle_idle_chat(task) self._handle_idle_chat(task)
self._tasks.complete() self._tasks.complete()
return return
if task.callback == "on_container_opened": # Skip placeholder actions
# Move to container first, then open it
self._execute_action(task)
return
# Skip "status" placeholder actions
if task.action == "status": if task.action == "status":
self._tasks.complete() self._tasks.complete()
return return
@ -204,21 +581,20 @@ class DougBrain(QObject):
if task.description and task.priority >= Priority.LOW: if task.description and task.priority >= Priority.LOW:
log.info(f"[{task.priority.name}] {task.description}") log.info(f"[{task.priority.name}] {task.description}")
# Execute the action
self._execute_action(task) self._execute_action(task)
def _execute_action(self, task: Task): def _execute_action(self, task: Task):
"""Send an action to the bridge.""" """Send an action to the bridge and mark brain as busy."""
self._waiting_for_action = True 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 {}
# Handle craft results specifically # Craft results
if task.action == "craft_item": if task.action == "craft_item":
self._waiting_for_action = False 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", {
@ -231,51 +607,115 @@ class DougBrain(QObject):
self._tasks.cancel() self._tasks.cancel()
return return
# Handle other results with error messages # Instant-complete actions
if task.action in ("open_chest", "dig_block", "equip_item"): if task.action in ("open_chest", "dig_block", "equip_item",
self._waiting_for_action = False "look_at", "send_chat", "attack_nearest_hostile"):
self._state = BrainState.IDLE
self._tasks.complete() self._tasks.complete()
return return
# For non-movement actions, complete immediately # Movement actions wait for movement_complete event
if task.action not in ("move_to", "move_relative", "follow_player"): if task.action not in ("move_to", "move_relative", "follow_player"):
self._waiting_for_action = False self._state = BrainState.IDLE
self._tasks.complete() self._tasks.complete()
else: else:
self._waiting_for_action = False self._state = BrainState.IDLE
self._tasks.cancel() self._tasks.cancel()
error = resp.error or "Something went wrong" error = resp.error or "Something went wrong"
log.debug(f"Action failed: {error}") log.debug(f"Action failed: {error}")
# Report failure to chat for player-initiated tasks
if task.priority >= Priority.HIGH: if task.priority >= Priority.HIGH:
self._ws.send_request("send_chat", {"message": error}) self._ws.send_request("send_chat", {"message": error})
self._ws.send_request(task.action, task.params, on_response) self._ws.send_request(task.action, task.params, on_response)
def _handle_idle_chat(self, task: Task): # ── Helpers ──
"""Handle unprompted chat — ask AI what to say."""
# Build context about what's happening
context_parts = []
if self._behaviors.nearby_players:
names = [p["name"] for p in self._behaviors.nearby_players]
context_parts.append(f"Players nearby: {', '.join(names)}")
if self._behaviors.is_night:
context_parts.append("It's nighttime")
if self._behaviors.is_raining:
context_parts.append("It's raining")
if self._behaviors.health < 10:
context_parts.append(f"Health is low ({self._behaviors.health})")
if self._behaviors.nearby_hostiles:
types = [h["type"] for h in self._behaviors.nearby_hostiles[:3]]
context_parts.append(f"Nearby mobs: {', '.join(types)}")
context = "; ".join(context_parts) if context_parts else "Nothing special happening" def _handle_idle_chat(self, task: Task):
self.wants_ai_chat.emit(context, "Say something to the players nearby. Keep it natural and short.") """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:
names = [p["name"] for p in b.nearby_players]
parts.append(f"Players nearby: {', '.join(names)}")
if b.is_night:
parts.append("It's nighttime")
if b.is_raining:
parts.append("It's raining")
if b.health < 10:
parts.append(f"Health is low ({b.health})")
if b.nearby_hostiles:
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']}")
# Add need context
critical_needs = self._needs.get_critical_needs()
if critical_needs:
parts.append(f"Feeling: {', '.join(critical_needs)}")
return "; ".join(parts) if parts else "Nothing special happening"
def _nearest_hostile(self, max_dist: float) -> dict | None:
closest = None
closest_dist = max_dist
for h in self._behaviors.nearby_hostiles:
d = h.get("distance", 99)
if d < closest_dist:
closest = h
closest_dist = d
return closest
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)
def _find_food(self) -> str | None:
"""Find food in inventory."""
food_items = {
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
"golden_apple", "melon_slice", "sweet_berries", "baked_potato",
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie",
"pumpkin_pie", "cake", "dried_kelp", "carrot", "potato",
}
for item in self._behaviors.inventory:
if item.get("name", "").replace("minecraft:", "") in food_items:
return item["name"]
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 @property
def current_action(self) -> str: def current_action(self) -> str:
task = self._tasks.current_task task = self._tasks.current_task
return task.name if task else "idle" if task:
return task.description or task.name
goal = self._goals.get_active_goal()
if goal:
return goal["description"]
return "idle"
@property @property
def is_night(self) -> bool: def is_night(self) -> bool:

View file

@ -58,6 +58,7 @@ class CommandParser:
CRAFT_PATTERNS = [ CRAFT_PATTERNS = [
r"(?:craft|make|build|create)\s+(.+)", r"(?:craft|make|build|create)\s+(.+)",
r"(?:can you|could you|please)\s+(?:craft|make|build|create)\s+(.+)",
] ]
MINE_PATTERNS = [ MINE_PATTERNS = [
@ -161,21 +162,41 @@ class CommandParser:
) )
return None return None
# Number words for quantity parsing
NUMBER_WORDS = {
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"a": 1, "an": 1,
}
def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]: def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]:
for pattern in self.CRAFT_PATTERNS: for pattern in self.CRAFT_PATTERNS:
match = re.search(pattern, msg, re.IGNORECASE) match = re.search(pattern, msg, re.IGNORECASE)
if match: if match:
raw_item = match.group(1).strip() if match.lastindex else "" raw_item = match.group(match.lastindex).strip() if match.lastindex else ""
# Extract item name: take first 1-3 non-filler words before any preposition # Extract quantity and item name
filler = {"a", "an", "some", "me", "the", "this", "that", "please"} filler = {"some", "me", "the", "this", "that", "please", "of"}
stop_words = {"with", "from", "using", "in", "on", "at", "for", "out", "of"} stop_words = {"with", "from", "using", "in", "on", "at", "for", "out"}
words = [] words = []
count = 1
for w in raw_item.split(): for w in raw_item.split():
wl = w.lower().rstrip(".,!?") wl = w.lower().rstrip(".,!?")
if wl in filler: if wl in filler:
continue continue
if wl in stop_words: if wl in stop_words:
break # Stop at prepositions break # Stop at prepositions
# Check for quantity (number or number word)
if not words:
if wl.isdigit():
count = int(wl)
continue
if wl in self.NUMBER_WORDS:
count = self.NUMBER_WORDS[wl]
continue
# "a" and "an" are quantity 1 when before the item
if not words and wl in ("a", "an"):
count = 1
continue
words.append(wl) words.append(wl)
if len(words) >= 3: if len(words) >= 3:
break break
@ -185,6 +206,7 @@ class CommandParser:
return ParsedCommand( return ParsedCommand(
action="craft", action="craft",
target=item, target=item,
params={"count": count},
raw_message=msg, raw_message=msg,
) )
return None return None
@ -284,13 +306,14 @@ def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]:
) )
elif cmd.action == "craft": elif cmd.action == "craft":
count = cmd.params.get("count", 1) if cmd.params else 1
return Task( return Task(
name=f"craft_{cmd.target}", name=f"craft_{cmd.target}",
priority=Priority.HIGH, priority=Priority.HIGH,
action="craft_item", action="craft_item",
params={"itemName": cmd.target, "count": 1}, params={"itemName": cmd.target, "count": count},
description=f"Crafting {cmd.target.replace('_', ' ')}", description=f"Crafting {count}x {cmd.target.replace('_', ' ')}",
timeout=15, timeout=30,
) )
elif cmd.action == "mine": elif cmd.action == "mine":

View file

@ -156,6 +156,24 @@ class MainWindow(QMainWindow):
f"Ollama server not reachable at {self.config.ollama_url}" f"Ollama server not reachable at {self.config.ollama_url}"
) )
# Pre-warm the model in background so the first real chat is fast
model = self.config.get("ollama_model", "")
if model and self._ollama:
self.dashboard.log_viewer.append_system(
f"Pre-warming Ollama model '{model}'..."
)
def _on_warm_done(success, _ollama=self._ollama, _model=model):
if success:
log.info("Ollama model pre-warmed successfully")
# Start keep-alive pings to prevent model unload
if _ollama and not _ollama._closed:
_ollama.start_keepalive(_model)
else:
log.warning("Failed to pre-warm Ollama model (will retry on first chat)")
self._ollama.warm_model_async(model, callback=_on_warm_done)
# Start Node.js bridge # Start Node.js bridge
ws_port = self._ws_port_counter ws_port = self._ws_port_counter
self._ws_port_counter += 1 self._ws_port_counter += 1
@ -232,6 +250,7 @@ class MainWindow(QMainWindow):
self._node_manager = None self._node_manager = None
if self._ollama: if self._ollama:
self._ollama.stop_keepalive()
self._ollama.close() self._ollama.close()
self._ollama = None self._ollama = None
@ -476,9 +495,8 @@ class MainWindow(QMainWindow):
return False return False
def _generate_response(self, sender: str, message: str): def _generate_response(self, sender: str, message: str):
"""Generate an AI response to a chat message.""" """Generate an AI response to a chat message using OllamaClient."""
import time as _time import threading
import httpx
if not self._active_doug: if not self._active_doug:
return return
@ -490,6 +508,10 @@ class MainWindow(QMainWindow):
self.dashboard.log_viewer.append_error("No Ollama model configured!") self.dashboard.log_viewer.append_error("No Ollama model configured!")
return return
if not self._ollama:
self.dashboard.log_viewer.append_error("Ollama client not initialized!")
return
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})") self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})")
# Build context from brain state # Build context from brain state
@ -512,51 +534,34 @@ class MainWindow(QMainWindow):
custom_notes=doug.custom_notes, custom_notes=doug.custom_notes,
) )
# Prepare messages # Build chat history from recent messages
messages = [{"role": "system", "content": system_prompt}] chat_history = []
# Add last few chat messages for context
recent = self._chat_repo.get_recent(doug.id, limit=3) recent = self._chat_repo.get_recent(doug.id, limit=3)
for msg in reversed(recent): for msg in reversed(recent):
role = "assistant" if msg["sender"] == doug.name else "user" role = "assistant" if msg["sender"] == doug.name else "user"
messages.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"}) chat_history.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"})
messages.append({"role": "user", "content": f"{sender}: {message}"})
ollama_url = self.config.ollama_url user_message = f"{sender}: {message}"
doug_id = doug.id doug_id = doug.id
doug_name = doug.name doug_name = doug.name
ollama = self._ollama
# Direct HTTP call in a thread — same approach as terminal test
import threading
def _do_request(): def _do_request():
t0 = _time.time() reply = ollama.chat(
try: model=model,
client = httpx.Client(timeout=30.0) system_prompt=system_prompt,
resp = client.post(f"{ollama_url}/api/chat", json={ user_message=user_message,
"model": model, chat_history=chat_history,
"messages": messages, temperature=0.8,
"stream": False, num_predict=25,
"think": False, )
"options": {"temperature": 0.8, "num_predict": 25},
})
resp.raise_for_status()
data = resp.json()
reply = data.get("message", {}).get("content", "").strip()
client.close()
elapsed = _time.time() - t0 if reply:
log.info(f"Ollama responded in {elapsed:.1f}s: {reply[:60]}") self._chat_response_ready.emit(reply, doug_id, doug_name)
else:
log.error("Ollama returned no response (after retry)")
if reply: thread = threading.Thread(target=_do_request, daemon=True, name="ollama-chat")
# Emit signal to handle response on main thread
self._chat_response_ready.emit(reply, doug_id, doug_name)
else:
log.error("Empty response from Ollama")
except Exception as e:
log.error(f"Ollama request failed: {e}")
thread = threading.Thread(target=_do_request, daemon=True)
thread.start() thread.start()
def _send_chat_response(self, response: str, doug_id: int, doug_name: str): def _send_chat_response(self, response: str, doug_id: int, doug_name: str):

425
test-combat.js Normal file
View file

@ -0,0 +1,425 @@
/**
* Combat Test Script
*
* Connects to a Bedrock BDS server and attempts to attack the nearest mob
* using multiple methods to determine which one actually deals damage.
*
* Usage:
* node --experimental-strip-types --disable-warning=ExperimentalWarning test-combat.js
*
* Run from the bridge/ directory so node_modules resolve correctly.
*/
// --- Ensure bridge/node_modules is on the require path ---
const path = require('path');
const Module = require('module');
const bridgeDir = path.join(__dirname, 'bridge');
const originalResolvePaths = Module._nodeModulePaths;
Module._nodeModulePaths = function(from) {
const paths = originalResolvePaths.call(this, from);
const bridgeNodeModules = path.join(bridgeDir, 'node_modules');
if (!paths.includes(bridgeNodeModules)) {
paths.unshift(bridgeNodeModules);
}
return paths;
};
// --- Patch prismarine-physics (same as bridge/src/index.js) ---
const origPhysicsPath = require.resolve('prismarine-physics');
const origPhysics = require('prismarine-physics');
const { Physics: OrigPhysics, PlayerState } = origPhysics;
function PatchedPhysics(mcData, world) {
if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) {
const attrs = [
{ name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 },
{ name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 },
{ name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 },
{ name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 },
{ name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 },
{ name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 },
{ name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 },
{ name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 },
{ name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 },
];
mcData.attributesArray = attrs;
mcData.attributes = {};
mcData.attributesByName = {};
for (const attr of attrs) {
mcData.attributes[attr.resource] = attr;
mcData.attributesByName[attr.name] = attr;
}
}
return OrigPhysics(mcData, world);
}
require.cache[origPhysicsPath] = {
id: origPhysicsPath,
exports: { Physics: PatchedPhysics, PlayerState },
loaded: true,
};
const mineflayer = require('./bridge/lib/mineflayer');
const { pathfinder: pathfinderPlugin, Movements, goals } = require('mineflayer-pathfinder');
const { GoalNear, GoalFollow } = goals;
const { Vec3 } = require('vec3');
const HOST = '192.168.1.90';
const PORT = 19140;
const USERNAME = 'CombatTest';
const NAMED_ENTITY_HEIGHT = 1.62;
console.log(`[CombatTest] Connecting to ${HOST}:${PORT} as ${USERNAME}...`);
const bot = mineflayer.createBot({
host: HOST,
port: PORT,
username: USERNAME,
version: 'bedrock_1.21.130',
bedrockProtocolVersion: '26.10',
auth: 'offline',
offline: true,
raknetBackend: 'jsp-raknet',
respawn: true,
});
bot.loadPlugin(pathfinderPlugin);
// Track entity health changes via update_attributes
const entityHealthTracker = new Map();
bot._client.on('update_attributes', (packet) => {
for (const attr of packet.attributes) {
if (attr.name === 'minecraft:health') {
const prev = entityHealthTracker.get(packet.runtime_entity_id);
entityHealthTracker.set(packet.runtime_entity_id, attr.current);
if (prev !== undefined && attr.current < prev) {
console.log(`[HealthTracker] Entity ${packet.runtime_entity_id} took damage: ${prev} -> ${attr.current}`);
}
}
}
});
// Track entity_event for hurt animations
bot._client.on('entity_event', (packet) => {
if (packet.event_id === 'hurt_animation' || packet.event_id === 2 || packet.event_id === 'hurt') {
console.log(`[EntityEvent] Entity ${packet.runtime_entity_id} received hurt event (event_id=${packet.event_id})`);
}
});
// Track animate packets for server-side swing confirmation
bot._client.on('animate', (packet) => {
if (packet.runtime_entity_id === bot.entity?.id) {
console.log(`[Animate] Server acknowledged arm swing (action_id=${packet.action_id})`);
}
});
let spawned = false;
let movements = null;
bot.once('spawn', async () => {
spawned = true;
movements = new Movements(bot);
movements.canDig = false;
movements.canOpenDoors = false;
bot.pathfinder.setMovements(movements);
const pos = bot.entity.position;
console.log(`[CombatTest] Spawned at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
console.log(`[CombatTest] Entity ID: ${bot.entity.id}`);
console.log(`[CombatTest] quickBarSlot: ${bot.quickBarSlot}`);
console.log(`[CombatTest] heldItem: ${JSON.stringify(bot.heldItem)}`);
// Wait a moment for entities to load
console.log('[CombatTest] Waiting 3s for entities to populate...');
await sleep(3000);
// Find nearest mob
const target = bot.nearestEntity((e) => e.type === 'mob' || (e.type === 'object' && e.name !== 'unknown' && e.name !== 'item'));
if (!target) {
console.log('[CombatTest] No mobs found nearby! Listing all entities:');
for (const [id, e] of Object.entries(bot.entities)) {
if (e === bot.entity) continue;
console.log(` Entity ${id}: type=${e.type} name=${e.name} pos=${e.position}`);
}
console.log('[CombatTest] Try spawning mobs near the bot with /summon');
// Keep running so we can try later
waitForMobAndTest();
return;
}
await runCombatTests(target);
});
async function waitForMobAndTest() {
console.log('[CombatTest] Polling for mobs every 5s...');
const interval = setInterval(async () => {
const target = bot.nearestEntity((e) => e.type === 'mob' || (e.type === 'object' && e.name !== 'unknown' && e.name !== 'item'));
if (target) {
clearInterval(interval);
await runCombatTests(target);
}
}, 5000);
}
async function runCombatTests(target) {
console.log(`\n[CombatTest] ====== TARGET FOUND ======`);
console.log(`[CombatTest] Target: ${target.name} (id=${target.id}, type=${target.type})`);
console.log(`[CombatTest] Target position: ${target.position}`);
console.log(`[CombatTest] Target height: ${target.height}`);
const dist = bot.entity.position.distanceTo(target.position);
console.log(`[CombatTest] Distance: ${dist.toFixed(2)}`);
// Walk towards the target if too far
if (dist > 3.5) {
console.log(`[CombatTest] Walking to target...`);
try {
bot.pathfinder.setGoal(new GoalNear(target.position.x, target.position.y, target.position.z, 2));
await sleep(5000);
} catch (e) {
console.log(`[CombatTest] Pathfinding issue: ${e.message}`);
}
bot.pathfinder.setGoal(null);
}
const finalDist = bot.entity.position.distanceTo(target.position);
console.log(`[CombatTest] Final distance: ${finalDist.toFixed(2)}`);
if (finalDist > 6) {
console.log('[CombatTest] Still too far. Teleport closer or summon a mob near the bot.');
return;
}
// Item helpers
const Item = require('prismarine-item')(bot.registry);
function getHeldItemNotch() {
const item = bot.heldItem;
if (!item) return { network_id: 0 };
return Item.toNotch(item, 0);
}
// ========================================================================
// METHOD 1: inventory_transaction with item_use_on_entity (FIXED VERSION)
// This is the standard Bedrock protocol way to attack entities.
// ========================================================================
console.log(`\n[CombatTest] --- METHOD 1: inventory_transaction (fixed) ---`);
{
// Look at the target first
const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0);
await bot.lookAt(targetEyePos, true);
await sleep(100);
// Swing arm first
bot.swingArm();
const heldItemNotch = getHeldItemNotch();
const playerPos = {
x: bot.entity.position.x,
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
z: bot.entity.position.z,
};
const clickPos = {
x: target.position.x - bot.entity.position.x,
y: (target.position.y + (target.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
z: target.position.z - bot.entity.position.z,
};
const packet = {
transaction: {
legacy: { legacy_request_id: 0 },
transaction_type: 'item_use_on_entity',
actions: [],
transaction_data: {
entity_runtime_id: target.id,
action_type: 'attack',
hotbar_slot: bot.quickBarSlot ?? 0,
held_item: heldItemNotch,
player_pos: playerPos,
click_pos: clickPos,
},
},
};
console.log(`[CombatTest] Sending inventory_transaction:`, JSON.stringify(packet, null, 2));
bot._client.write('inventory_transaction', packet);
console.log('[CombatTest] Sent! Waiting 2s to observe result...');
await sleep(2000);
}
// ========================================================================
// METHOD 2: bot.attack() using the mineflayer API (uses the updated code)
// ========================================================================
console.log(`\n[CombatTest] --- METHOD 2: bot.attack() (mineflayer API) ---`);
{
// Re-find the target in case it moved
const currentTarget = bot.entities[target.id];
if (currentTarget) {
console.log(`[CombatTest] Calling bot.attack(target)...`);
await bot.attack(currentTarget);
console.log('[CombatTest] Sent! Waiting 2s to observe result...');
await sleep(2000);
} else {
console.log('[CombatTest] Target entity no longer exists');
}
}
// ========================================================================
// METHOD 3: player_action with missed_swing (tests if server needs this)
// ========================================================================
console.log(`\n[CombatTest] --- METHOD 3: player_action missed_swing ---`);
{
// Look at target
const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0);
await bot.lookAt(targetEyePos, true);
const actionPacket = {
runtime_entity_id: bot.entity.id,
action: 'missed_swing',
position: { x: 0, y: 0, z: 0 },
result_position: { x: 0, y: 0, z: 0 },
face: 0,
};
console.log(`[CombatTest] Sending player_action (missed_swing)...`);
try {
bot._client.write('player_action', actionPacket);
console.log('[CombatTest] Sent (this should NOT deal damage by itself).');
} catch (e) {
console.log(`[CombatTest] Error: ${e.message}`);
}
await sleep(1000);
}
// ========================================================================
// METHOD 4: Rapid attack combo (swing + transaction, 5 times, 500ms apart)
// Tests if cooldown or timing matters
// ========================================================================
console.log(`\n[CombatTest] --- METHOD 4: Rapid attack combo (5x, 500ms apart) ---`);
{
for (let i = 0; i < 5; i++) {
const currentTarget = bot.entities[target.id];
if (!currentTarget) {
console.log(`[CombatTest] Target died or despawned after ${i} hits!`);
break;
}
// Look at target
const targetEyePos = currentTarget.position.offset(0, (currentTarget.height || 0) * 0.5, 0);
await bot.lookAt(targetEyePos, true);
// Swing arm
bot.swingArm();
// Send attack
const heldItemNotch = getHeldItemNotch();
bot._client.write('inventory_transaction', {
transaction: {
legacy: { legacy_request_id: 0 },
transaction_type: 'item_use_on_entity',
actions: [],
transaction_data: {
entity_runtime_id: currentTarget.id,
action_type: 'attack',
hotbar_slot: bot.quickBarSlot ?? 0,
held_item: heldItemNotch,
player_pos: {
x: bot.entity.position.x,
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
z: bot.entity.position.z,
},
click_pos: {
x: currentTarget.position.x - bot.entity.position.x,
y: (currentTarget.position.y + (currentTarget.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
z: currentTarget.position.z - bot.entity.position.z,
},
},
},
});
console.log(`[CombatTest] Attack ${i + 1}/5 sent`);
await sleep(500);
}
console.log('[CombatTest] Waiting 2s to observe results...');
await sleep(2000);
}
// ========================================================================
// METHOD 5: interact packet (mouse_over_entity) before attack
// Some servers need the interact packet first to register targeting
// ========================================================================
console.log(`\n[CombatTest] --- METHOD 5: interact (mouse_over) + attack ---`);
{
const currentTarget = bot.entities[target.id];
if (currentTarget) {
// Look at target
const targetEyePos = currentTarget.position.offset(0, (currentTarget.height || 0) * 0.5, 0);
await bot.lookAt(targetEyePos, true);
// Send interact mouse_over_entity first
try {
bot._client.write('interact', {
action_id: 'mouse_over_entity',
target_entity_id: currentTarget.id,
has_position: false,
});
console.log('[CombatTest] Sent interact mouse_over_entity');
} catch (e) {
console.log(`[CombatTest] interact packet error: ${e.message}`);
}
await sleep(100);
// Then swing and attack
bot.swingArm();
const heldItemNotch = getHeldItemNotch();
bot._client.write('inventory_transaction', {
transaction: {
legacy: { legacy_request_id: 0 },
transaction_type: 'item_use_on_entity',
actions: [],
transaction_data: {
entity_runtime_id: currentTarget.id,
action_type: 'attack',
hotbar_slot: bot.quickBarSlot ?? 0,
held_item: heldItemNotch,
player_pos: {
x: bot.entity.position.x,
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
z: bot.entity.position.z,
},
click_pos: {
x: currentTarget.position.x - bot.entity.position.x,
y: (currentTarget.position.y + (currentTarget.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
z: currentTarget.position.z - bot.entity.position.z,
},
},
},
});
console.log('[CombatTest] Sent attack after interact. Waiting 2s...');
await sleep(2000);
} else {
console.log('[CombatTest] Target no longer exists');
}
}
console.log('\n[CombatTest] ====== ALL TESTS COMPLETE ======');
console.log('[CombatTest] Check output above for:');
console.log(' - [HealthTracker] lines = entity took damage (health decreased)');
console.log(' - [EntityEvent] lines = hurt animation played');
console.log(' - [Animate] lines = server acknowledged arm swing');
console.log('[CombatTest] Bot will stay connected for manual testing. Press Ctrl+C to exit.');
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
bot.on('error', (err) => {
console.error('[CombatTest] Error:', err.message);
});
bot.on('end', (reason) => {
console.log('[CombatTest] Disconnected:', reason);
process.exit(0);
});
bot.on('kicked', (reason) => {
console.log('[CombatTest] Kicked:', reason);
});

275
test-craft.js Normal file
View file

@ -0,0 +1,275 @@
/**
* Test script for DougBot crafting connects to BDS and tests crafting sticks.
*
* Usage:
* node --experimental-strip-types --disable-warning=ExperimentalWarning test-craft.js
*
* Or with full path:
* /Users/roberts/.local/share/fnm/node-versions/v22.22.2/installation/bin/node \
* --experimental-strip-types --disable-warning=ExperimentalWarning test-craft.js
*/
// --- Patch prismarine-physics for missing bedrock attributes ---
const origPhysicsPath = require.resolve('prismarine-physics');
const origPhysics = require('prismarine-physics');
const { Physics: OrigPhysics, PlayerState } = origPhysics;
function PatchedPhysics(mcData, world) {
if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) {
const attrs = [
{ name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 },
{ name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 },
{ name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 },
{ name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 },
{ name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 },
{ name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 },
{ name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 },
{ name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 },
{ name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 },
];
mcData.attributesArray = attrs;
mcData.attributes = {};
mcData.attributesByName = {};
for (const attr of attrs) {
mcData.attributes[attr.resource] = attr;
mcData.attributesByName[attr.name] = attr;
}
}
return OrigPhysics(mcData, world);
}
require.cache[origPhysicsPath] = {
id: origPhysicsPath,
exports: { Physics: PatchedPhysics, PlayerState },
loaded: true,
};
// --- Imports ---
const path = require('path');
const mineflayer = require(path.join(__dirname, 'bridge', 'lib', 'mineflayer'));
const { pathfinder: pathfinderPlugin, Movements, goals } = require('mineflayer-pathfinder');
const { GoalNear } = goals;
// --- Config ---
const HOST = process.env.BDS_HOST || '192.168.1.90';
const PORT = parseInt(process.env.BDS_PORT || '19140');
const USERNAME = process.env.BDS_USER || 'CraftTest';
function log(msg) {
console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
}
log(`Connecting to ${HOST}:${PORT} as ${USERNAME}...`);
const bot = mineflayer.createBot({
host: HOST,
port: PORT,
username: USERNAME,
version: 'bedrock_1.21.130',
bedrockProtocolVersion: '26.10',
auth: 'offline',
offline: true,
raknetBackend: 'jsp-raknet',
});
bot.loadPlugin(pathfinderPlugin);
let testPhase = 'waiting';
bot.once('spawn', async () => {
log('Spawned! Setting up pathfinder...');
const movements = new Movements(bot);
movements.canDig = false;
bot.pathfinder.setMovements(movements);
const pos = bot.entity.position;
log(`Position: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
// Wait a moment for recipes to load
await sleep(3000);
// --- Run test sequence ---
try {
await testResolveNames();
await testInventoryCheck();
await testCraftSticks();
log('=== ALL TESTS COMPLETE ===');
} catch (e) {
log(`TEST FAILED: ${e.message}`);
console.error(e);
}
// Disconnect after tests
await sleep(2000);
log('Disconnecting...');
bot.quit();
setTimeout(() => process.exit(0), 1000);
});
bot.on('error', (err) => {
log(`ERROR: ${err.message}`);
});
bot.on('kicked', (reason) => {
log(`KICKED: ${reason}`);
process.exit(1);
});
// --- Test: Name Resolution ---
async function testResolveNames() {
log('--- TEST: Item Name Resolution ---');
const mcData = require('minecraft-data')(bot.version);
const testCases = [
['sticks', 'stick'],
['stick', 'stick'],
['planks', 'planks'],
['oak_planks', 'oak_planks'],
['wooden_pickaxe', 'wooden_pickaxe'],
['wood_pickaxe', 'wooden_pickaxe'],
['wooden pickaxe', 'wooden_pickaxe'],
['crafting_table', 'crafting_table'],
['workbench', 'crafting_table'],
['torches', 'torch'],
['iron', 'iron_ingot'],
['cobble', 'cobblestone'],
['stone_sword', 'stone_sword'],
['diamond_pickaxe', 'diamond_pickaxe'],
];
// Inline version of resolveItemName for testing
const ITEM_ALIASES = {
'sticks': 'stick', 'planks': 'planks', 'torches': 'torch', 'logs': 'oak_log',
'wood': 'oak_log', 'wood_planks': 'planks', 'plank': 'planks',
'log': 'oak_log', 'cobble': 'cobblestone',
'wood_pickaxe': 'wooden_pickaxe', 'wood_sword': 'wooden_sword',
'wood_axe': 'wooden_axe', 'wood_shovel': 'wooden_shovel', 'wood_hoe': 'wooden_hoe',
'workbench': 'crafting_table', 'bench': 'crafting_table',
'iron': 'iron_ingot', 'gold': 'gold_ingot',
};
function resolveItemName(rawName) {
let name = rawName.toLowerCase().trim().replace(/\s+/g, '_');
if (ITEM_ALIASES[name]) name = ITEM_ALIASES[name];
let item = mcData.itemsByName[name];
if (item) return item.name;
if (name.endsWith('s') && !name.endsWith('ss')) {
item = mcData.itemsByName[name.slice(0, -1)];
if (item) return item.name;
}
if (name.startsWith('wooden_')) {
item = mcData.itemsByName[name.replace('wooden_', 'wood_')];
if (item) return item.name;
}
if (name.startsWith('wood_')) {
item = mcData.itemsByName[name.replace('wood_', 'wooden_')];
if (item) return item.name;
}
for (const [n] of Object.entries(mcData.itemsByName)) {
if (n === name || n.includes(name) || name.includes(n)) return n;
}
return null;
}
let passed = 0;
for (const [input, expected] of testCases) {
const result = resolveItemName(input);
const ok = result === expected;
log(` ${ok ? 'PASS' : 'FAIL'}: "${input}" → "${result}" (expected "${expected}")`);
if (ok) passed++;
}
log(` Results: ${passed}/${testCases.length} passed`);
}
// --- Test: Inventory Check ---
async function testInventoryCheck() {
log('--- TEST: Inventory Contents ---');
const items = bot.inventory.items();
if (items.length === 0) {
log(' Inventory is EMPTY. Give CraftTest some planks to test crafting.');
log(' Use: /give CraftTest oak_planks 4');
} else {
for (const item of items) {
log(` ${item.count}x ${item.name} (id=${item.type}, slot=${item.slot})`);
}
}
}
// --- Test: Craft Sticks ---
async function testCraftSticks() {
log('--- TEST: Craft Sticks ---');
const mcData = require('minecraft-data')(bot.version);
// Check if we have planks
const planks = bot.inventory.items().find(i => {
const n = i.name.replace('minecraft:', '');
return n === 'planks' || n.endsWith('_planks');
});
if (!planks) {
log(' SKIP: No planks in inventory. Give CraftTest planks first.');
log(' Hint: /give CraftTest oak_planks 4');
return;
}
log(` Have ${planks.count}x ${planks.name}`);
// Look up stick item
const stickItem = mcData.itemsByName['stick'];
if (!stickItem) {
log(' FAIL: "stick" not found in minecraft-data!');
return;
}
log(` stick item id = ${stickItem.id}`);
// Check recipes
const recipesNoTable = bot.recipesFor(stickItem.id, null, null, undefined);
log(` Recipes without table: ${recipesNoTable.length}`);
// Find crafting table nearby
const craftingTable = bot.findBlock({
matching: (block) => {
const n = block.name.replace('minecraft:', '');
return n === 'crafting_table' || n === 'workbench';
},
maxDistance: 32,
});
if (craftingTable) {
log(` Found crafting table at ${craftingTable.position}`);
const recipesWithTable = bot.recipesFor(stickItem.id, null, null, craftingTable);
log(` Recipes with table: ${recipesWithTable.length}`);
} else {
log(' No crafting table found nearby');
}
// Try to craft (sticks are 2x2, should work without a table)
const recipes = recipesNoTable.length > 0 ? recipesNoTable :
(craftingTable ? bot.recipesFor(stickItem.id, null, null, craftingTable) : []);
if (recipes.length === 0) {
log(' FAIL: No recipes available for sticks');
return;
}
log(` Using recipe: ${JSON.stringify(recipes[0], null, 2).slice(0, 200)}`);
try {
const table = recipesNoTable.length > 0 ? undefined : craftingTable;
await bot.craft(recipes[0], 1, table);
log(' SUCCESS: Crafted sticks!');
// Verify sticks in inventory
const sticks = bot.inventory.items().find(i => i.name.replace('minecraft:', '') === 'stick');
if (sticks) {
log(` Verified: ${sticks.count}x stick in inventory`);
}
} catch (e) {
log(` FAIL: Craft error: ${e.message}`);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}