From 195ef2d8600b23c4c30aa189e11a84a127df3db2 Mon Sep 17 00:00:00 2001 From: roberts Date: Mon, 30 Mar 2026 16:07:23 -0500 Subject: [PATCH] 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) --- .../lib/bedrockPlugins/entities.mts | 65 +- bridge/src/index.js | 334 +++++-- dougbot/ai/ollama_client.py | 195 +++- dougbot/core/behaviors.py | 908 +++++++++++------- dougbot/core/brain.py | 620 ++++++++++-- dougbot/core/command_parser.py | 37 +- dougbot/gui/main_window.py | 81 +- test-combat.js | 425 ++++++++ test-craft.js | 275 ++++++ 9 files changed, 2348 insertions(+), 592 deletions(-) create mode 100644 test-combat.js create mode 100644 test-craft.js diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts index f097e9e..f97b38f 100644 --- a/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts @@ -71,6 +71,13 @@ export default function inject(bot: BedrockBot) { 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 bot._client.on('start_game', (packet) => { bot.players = {}; @@ -523,16 +530,22 @@ export default function inject(bot: BedrockBot) { } function itemUseOnEntity(target: any, type: number) { const typeStr = ['attack', 'interact'][type]; - // Provide empty item if hand is empty to avoid serialization crash - const heldItem = bot.heldItem || { - network_id: 0, - count: 0, - metadata: 0, - has_stack_id: false, - stack_id: 0, - block_runtime_id: 0, - extra: { has_nbt: false, can_place_on: [], can_destroy: [] }, + // Convert held item to wire format using prismarine-item serialization + const heldItemNotch = heldItemToNotch(); + // Player position must include eye height (matching player_auth_input format) + const playerPos = { + x: bot.entity.position.x, + y: bot.entity.position.y + NAMED_ENTITY_HEIGHT, + z: bot.entity.position.z, }; + // 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 = { transaction: { legacy: { @@ -543,33 +556,27 @@ export default function inject(bot: BedrockBot) { transaction_data: { entity_runtime_id: target.id, action_type: typeStr, - hotbar_slot: bot.quickBarSlot, - held_item: heldItem, - player_pos: bot.entity.position, - click_pos: { - x: 0, - y: 0, - z: 0, - }, + hotbar_slot: bot.quickBarSlot ?? 0, + held_item: heldItemNotch, + player_pos: playerPos, + click_pos: clickPos, }, }, }; bot._client.write('inventory_transaction', transaction); } - function attack(target: any, swing = true) { - // arm animation comes before the use_entity packet on 1.8 - if (bot.supportFeature('armAnimationBeforeUse')) { - if (swing) { - bot.swingArm(); // in inventory - } - attackEntity(target); - } else { - attackEntity(target); - if (swing) { - bot.swingArm(); // in inventory - } + async function attack(target: any, swing = true) { + // Look at the target entity before attacking (server validates player aim) + if (target.position) { + const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0); + await bot.lookAt(targetEyePos, true); } + // On Bedrock, swing arm first then send the attack transaction + if (swing) { + bot.swingArm(); + } + attackEntity(target); } function fetchEntity(id: any) { diff --git a/bridge/src/index.js b/bridge/src/index.js index eb841f2..d8d33e1 100644 --- a/bridge/src/index.js +++ b/bridge/src/index.js @@ -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 --- const HOSTILE_MOBS = new Set([ 'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider', @@ -634,95 +817,132 @@ async function handleAction(action, params = {}) { case 'list_recipes': { const { itemName } = params; const mcData = require('minecraft-data')(bot.version); - const item = mcData.itemsByName[itemName]; - if (!item) return { recipes: [], error: `Unknown item: ${itemName}` }; - const recipes = bot.recipesFor(item.id); + const resolved = resolveItemName(itemName, mcData); + if (!resolved) return { recipes: [], error: `Unknown item: ${itemName}` }; + const recipes = bot.recipesFor(resolved.id); return { recipes: recipes.map((r, i) => ({ index: i, ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [], })), + resolvedName: resolved.name, }; } case 'craft_item': { const { itemName, count: craftCount } = params; const mcData = require('minecraft-data')(bot.version); + const wantCount = craftCount || 1; - // Try to find item — handle both "wooden_pickaxe" and "planks" style names - let item = mcData.itemsByName[itemName]; - if (!item) { - // 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) { + // Step 1: Resolve the item name to a real Bedrock item ID + const resolved = resolveItemName(itemName, mcData); + if (!resolved) { 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) - let craftingTable = bot.findBlock({ - matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'), - maxDistance: 32, - }); + // Step 2: Check inventory for required materials BEFORE doing anything + const materialCheck = checkCraftingMaterials(bot, realName, wantCount); + if (materialCheck && !materialCheck.canCraft) { + 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); - if (dist > 4) { - log('client', 'INFO', `Walking to crafting table at ${craftingTable.position}`); + if (dist > 3.5) { + log('client', 'INFO', `Walking to crafting table at ${craftingTable.position} (dist=${dist.toFixed(1)})`); bot.pathfinder.setGoal(new GoalNear( 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) => { const check = setInterval(() => { const d = bot.entity.position.distanceTo(craftingTable.position); - if (d <= 3) { clearInterval(check); resolve(); } - }, 500); - setTimeout(() => { clearInterval(check); resolve(); }, 15000); - }); - // 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, + if (d <= 3.5) { clearInterval(check); resolve(); } + }, 400); + setTimeout(() => { clearInterval(check); resolve(); }, 20000); }); } + + // 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 - const recipes = bot.recipesFor(item.id, null, null, craftingTable || undefined); + // Step 5: Look up recipes from mineflayer + const recipes = bot.recipesFor(resolved.id, null, null, craftingTable || undefined); if (recipes.length === 0) { - const reason = craftingTable - ? `I don't have the materials to craft ${itemName.replace(/_/g, ' ')}.` - : `I need a crafting table to make ${itemName.replace(/_/g, ' ')}.`; - return { crafted: false, error: reason }; + // Try without table in case mineflayer knows it as a 2x2 recipe + const recipes2x2 = bot.recipesFor(resolved.id, null, null, undefined); + if (recipes2x2.length > 0) { + // 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 { - await bot.craft(recipes[0], craftCount || 1, craftingTable || undefined); - log('client', 'INFO', `Crafted ${craftCount || 1}x ${itemName}`); - return { crafted: true, item: itemName, count: craftCount || 1 }; + await bot.craft(recipes[0], wantCount, craftingTable || undefined); + log('client', 'INFO', `Crafted ${wantCount}x ${realName}`); + return { crafted: true, item: realName, count: wantCount }; } catch (e) { + log('client', 'ERROR', `Craft failed: ${e.message}`); return { crafted: false, error: `Crafting failed: ${e.message}` }; } } diff --git a/dougbot/ai/ollama_client.py b/dougbot/ai/ollama_client.py index 487e136..c651cec 100644 --- a/dougbot/ai/ollama_client.py +++ b/dougbot/ai/ollama_client.py @@ -3,8 +3,10 @@ Ollama API client. Handles communication with the local Ollama server for AI reasoning. """ +import time +import threading import httpx -from typing import Optional, AsyncGenerator +from typing import Optional from dougbot.utils.logging import get_logger log = get_logger("ai.ollama") @@ -13,10 +15,29 @@ log = get_logger("ai.ollama") class OllamaClient: """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"): self.base_url = base_url.rstrip("/") - # Long timeout — first request loads the model into GPU/RAM which can take minutes - self._client = httpx.Client(timeout=300.0) + self._model_warm = False + 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]: """ @@ -37,6 +58,103 @@ class OllamaClient: log.error(f"Failed to list models: {e}") 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( self, model: str, @@ -44,23 +162,25 @@ class OllamaClient: user_message: str, chat_history: Optional[list[dict]] = None, temperature: float = 0.8, + num_predict: int = 150, ) -> Optional[str]: """ Send a chat request to Ollama and return the response. + Includes automatic retry with longer timeout on failure. 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) user_message: The user's message chat_history: Previous messages for context temperature: Creativity (0.0-1.0, higher = more creative) + num_predict: Max tokens to generate Returns: The AI response text, or None on failure. """ messages = [{"role": "system", "content": system_prompt}] - # Add conversation history if chat_history: for msg in chat_history: messages.append({ @@ -68,48 +188,75 @@ class OllamaClient: "content": msg.get("content", ""), }) - # Add current 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: response = self._client.post( f"{self.base_url}/api/chat", - json={ - "model": model, - "messages": messages, - "stream": False, - "think": False, # Disable thinking mode (Qwen3.5, etc.) - "options": { - "temperature": temperature, - "num_predict": 150, # Cap response length for chat - }, - }, + json=payload, + timeout=timeout, ) response.raise_for_status() data = response.json() - - reply = data.get("message", {}).get("content", "") - log.debug(f"Ollama response ({len(reply)} chars): {reply[:100]}...") - return reply + reply = data.get("message", {}).get("content", "").strip() + elapsed = time.time() - t0 + log.debug(f"Ollama response in {elapsed:.1f}s ({len(reply)} chars): {reply[:100]}") + return reply if reply else None except httpx.ConnectError: log.error(f"Cannot connect to Ollama at {self.base_url}") return None 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 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 def is_available(self) -> bool: """Check if the Ollama server is reachable.""" 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 except Exception: return False 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() diff --git a/dougbot/core/behaviors.py b/dougbot/core/behaviors.py index 41e8853..a0135bc 100644 --- a/dougbot/core/behaviors.py +++ b/dougbot/core/behaviors.py @@ -1,6 +1,12 @@ """ -Behavior modules for Doug. Each behavior generates tasks based on -world state and persona traits. +Behavior systems for Doug's goal-based brain. + +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 @@ -14,11 +20,551 @@ from dougbot.utils.logging import get_logger 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: - """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): - self._traits = traits # Persona trait values + self._traits = traits self._age = age self._name = doug_name @@ -37,349 +583,17 @@ class BehaviorEngine: self.inventory: list[dict] = [] self.spawn_pos = {"x": 0, "y": 0, "z": 0} - # Behavior state - self._last_scan_time = 0.0 - self._last_chat_time = 0.0 - self._last_wander_time = 0.0 - self._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 def is_night(self) -> bool: 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) diff --git a/dougbot/core/brain.py b/dougbot/core/brain.py index e6a8060..b341346 100644 --- a/dougbot/core/brain.py +++ b/dougbot/core/brain.py @@ -1,27 +1,47 @@ """ -Doug's Brain — the autonomous decision loop. -Uses behavior engine + task queue for trait-driven decisions. -Ticks every 2 seconds: scan → generate tasks → execute top task. +Doug's Brain — goal-based autonomous decision engine. + +Instead of randomly generating tasks each tick, Doug now has: +- A NEEDS system (safety, hunger, social, shelter, boredom) that drives urgency +- A GOALS system (long-term objectives broken into steps) +- A MEMORY system (remembers locations of things he's seen) +- A DAILY ROUTINE that adapts to time of day and persona traits + +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 random import time +from enum import IntEnum from PySide6.QtCore import QObject, QTimer, Signal from dougbot.bridge.ws_client import BridgeWSClient from dougbot.bridge.protocol import ResponseMessage from dougbot.core.task_queue import TaskQueue, Task, Priority -from dougbot.core.behaviors import BehaviorEngine +from dougbot.core.behaviors import ( + NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine, +) from dougbot.utils.logging import get_logger log = get_logger("core.brain") -class DougBrain(QObject): - """Autonomous decision engine with trait-driven behavior.""" +class BrainState(IntEnum): + """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_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say @@ -36,21 +56,33 @@ class DougBrain(QObject): # Core systems 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._last_scan_time = 0.0 - self._scan_interval = 3.0 # Seconds between full scans + self._scan_interval = 4.0 # seconds between scans - # Action state - self._waiting_for_action = False - self._action_sent_time = 0.0 + # Tick counter for staggering updates + self._tick_count = 0 + + # Chat throttle + self._last_chat_time = 0.0 def start(self): self._running = True 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): self._running = False @@ -59,6 +91,8 @@ class DougBrain(QObject): self._ws.send_request("stop", {}) log.info("Brain stopped") + # ── Event handling (same interface) ── + def update_from_event(self, event: str, data: dict): """Update brain state from bridge events.""" 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) } self._behaviors.spawn_pos = dict(self._behaviors.position) + self._memory.set_home(self._behaviors.position) elif event == "health_changed": self._behaviors.health = data.get("health", 20) self._behaviors.food = data.get("food", 20) + # Update safety need immediately on damage + self._needs.on_health_change(self._behaviors.health, self._behaviors.food) elif event == "time_update": self._behaviors.day_time = data.get("dayTime", 0) elif event == "movement_complete": - self._waiting_for_action = False + self._state = BrainState.IDLE self._tasks.complete() elif event == "movement_failed": - self._waiting_for_action = False + self._state = BrainState.IDLE self._tasks.cancel() elif event == "death": - self._waiting_for_action = False + self._state = BrainState.IDLE 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": username = data.get("username", "") if username and username != self._doug_name: + self._needs.on_player_nearby() log.info(f"Player joined: {username}") elif event == "player_left": @@ -98,41 +138,64 @@ class DougBrain(QObject): if username: log.info(f"Player left: {username}") + # ── Main tick ── + def _tick(self): - """Main brain tick — scan, generate tasks, execute.""" + """Main brain tick — needs → scan → decide → act.""" from PySide6.QtNetwork import QAbstractSocket if not self._running: return if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState: return - # Safety: unstick action timeout - if self._waiting_for_action and (time.time() - self._action_sent_time > 20): - self._waiting_for_action = False - self._tasks.cancel() + self._tick_count += 1 + + # Safety: unstick action timeout (20s) + 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 if self._pending_scan and (time.time() - self._last_scan_time > 10): 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): self._pending_scan = True self._last_scan_time = time.time() - self._ws.send_request("scan_surroundings", {"radius": 12}, self._on_scan) + self._ws.send_request("scan_surroundings", {"radius": 16}, self._on_scan) 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 not self._pending_scan and not self._waiting_for_action: - self._generate_tasks() + if self._pending_scan: + return # Still waiting for scan - # Step 3: Execute top task - if not self._waiting_for_action: - self._execute_next_task() + # Step 3: Decide what to do + task = self._decide() + 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): - """Process surroundings scan.""" + """Process scan results and update memory.""" self._pending_scan = False if response.status != "success": return @@ -148,54 +211,368 @@ class DougBrain(QObject): self._behaviors.nearby_signs = data.get("signs", []) self._behaviors.nearby_blocks = data.get("blocks", {}) - # Split entities into hostiles and others entities = data.get("entities", []) self._behaviors.nearby_entities = entities self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)] + # 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): - """Process inventory response.""" if response.status != "success": return self._behaviors.inventory = response.data.get("items", []) - def _generate_tasks(self): - """Ask behavior engine to generate tasks based on current state.""" - # Priority order: survival → combat → social → exploration → organization → idle - generators = [ - self._behaviors.get_survival_task, - self._behaviors.get_combat_task, - self._behaviors.get_social_task, - self._behaviors.get_exploration_task, - self._behaviors.get_organization_task, - self._behaviors.get_idle_task, - ] + # ── Decision Engine ── - for gen in generators: - task = gen() + def _decide(self) -> Task | None: + """ + The core decision: what should Doug do RIGHT NOW? + + Priority order: + 1. Critical survival (flee, eat) — if safety/hunger need is critical + 2. Player commands (already in queue at HIGH priority) + 3. Urgent needs (shelter at night, social when lonely) + 4. Current goal step (work toward long-term goals) + 5. Daily routine activity + 6. Idle behavior (look around, chat) + """ + b = self._behaviors + + # 1. CRITICAL SURVIVAL — safety need below 20 + if self._needs.safety < 20: + task = self._survival_task() if task: - should_execute = self._tasks.add(task) - if should_execute: - break # High-priority task added, execute immediately + return task + + # 2. HUNGER — food need below 25 + if self._needs.hunger < 25: + task = self._eat_task() + if task: + return task + + # 3. SHELTER — at night with low shelter need + if b.is_night and self._needs.shelter < 30: + task = self._shelter_task() + if task: + return task + + # 4. SOCIAL — respond to nearby players if social need is low + if self._needs.social < 30 and b.nearby_players: + task = self._social_task() + if task: + return task + + # 5. GOAL PROGRESS — work on the current goal + task = self._goal_task() + if task: + return task + + # 6. DAILY ROUTINE — what should Doug be doing at this time of day? + task = self._routine_task() + if task: + return task + + # 7. BOREDOM — do something interesting + if self._needs.boredom < 30: + task = self._boredom_task() + if task: + return task + + # 8. IDLE — look around, maybe chat + return self._idle_task() + + # ── Need-driven tasks ── + + def _survival_task(self) -> Task | None: + """Handle immediate survival threats.""" + 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): - """Execute the highest priority task.""" + """Execute the highest priority task from the queue.""" task = self._tasks.next() if not task: return - # Special callbacks + # Handle special callbacks if task.callback == "on_idle_chat": self._handle_idle_chat(task) self._tasks.complete() return - if task.callback == "on_container_opened": - # Move to container first, then open it - self._execute_action(task) - return - - # Skip "status" placeholder actions + # Skip placeholder actions if task.action == "status": self._tasks.complete() return @@ -204,21 +581,20 @@ class DougBrain(QObject): if task.description and task.priority >= Priority.LOW: log.info(f"[{task.priority.name}] {task.description}") - # Execute the action self._execute_action(task) def _execute_action(self, task: Task): - """Send an action to the bridge.""" - self._waiting_for_action = True + """Send an action to the bridge and mark brain as busy.""" + self._state = BrainState.EXECUTING_TASK self._action_sent_time = time.time() def on_response(resp: ResponseMessage): if resp.status == "success": data = resp.data or {} - # Handle craft results specifically + # Craft results if task.action == "craft_item": - self._waiting_for_action = False + self._state = BrainState.IDLE if data.get("crafted"): item = data.get("item", "item").replace("_", " ") self._ws.send_request("send_chat", { @@ -231,51 +607,115 @@ class DougBrain(QObject): self._tasks.cancel() return - # Handle other results with error messages - if task.action in ("open_chest", "dig_block", "equip_item"): - self._waiting_for_action = False + # Instant-complete actions + if task.action in ("open_chest", "dig_block", "equip_item", + "look_at", "send_chat", "attack_nearest_hostile"): + self._state = BrainState.IDLE self._tasks.complete() return - # For non-movement actions, complete immediately + # Movement actions wait for movement_complete event if task.action not in ("move_to", "move_relative", "follow_player"): - self._waiting_for_action = False + self._state = BrainState.IDLE self._tasks.complete() else: - self._waiting_for_action = False + self._state = BrainState.IDLE self._tasks.cancel() error = resp.error or "Something went wrong" log.debug(f"Action failed: {error}") - # Report failure to chat for player-initiated tasks if task.priority >= Priority.HIGH: self._ws.send_request("send_chat", {"message": error}) self._ws.send_request(task.action, task.params, on_response) - def _handle_idle_chat(self, task: Task): - """Handle unprompted chat — ask AI what to say.""" - # Build context about what's happening - context_parts = [] - if self._behaviors.nearby_players: - names = [p["name"] for p in self._behaviors.nearby_players] - context_parts.append(f"Players nearby: {', '.join(names)}") - if self._behaviors.is_night: - context_parts.append("It's nighttime") - if self._behaviors.is_raining: - context_parts.append("It's raining") - if self._behaviors.health < 10: - context_parts.append(f"Health is low ({self._behaviors.health})") - if self._behaviors.nearby_hostiles: - types = [h["type"] for h in self._behaviors.nearby_hostiles[:3]] - context_parts.append(f"Nearby mobs: {', '.join(types)}") + # ── Helpers ── - context = "; ".join(context_parts) if context_parts else "Nothing special happening" - self.wants_ai_chat.emit(context, "Say something to the players nearby. Keep it natural and short.") + def _handle_idle_chat(self, task: Task): + """Handle unprompted chat.""" + context = self._build_chat_context() + self.wants_ai_chat.emit( + context, + "Say something to the players nearby. Keep it natural and short." + ) + + def _build_chat_context(self) -> str: + """Build a context string describing what's happening.""" + parts = [] + b = self._behaviors + if b.nearby_players: + 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 def current_action(self) -> str: 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 def is_night(self) -> bool: diff --git a/dougbot/core/command_parser.py b/dougbot/core/command_parser.py index 050dfdb..d799b68 100644 --- a/dougbot/core/command_parser.py +++ b/dougbot/core/command_parser.py @@ -58,6 +58,7 @@ class CommandParser: CRAFT_PATTERNS = [ r"(?:craft|make|build|create)\s+(.+)", + r"(?:can you|could you|please)\s+(?:craft|make|build|create)\s+(.+)", ] MINE_PATTERNS = [ @@ -161,21 +162,41 @@ class CommandParser: ) 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]: for pattern in self.CRAFT_PATTERNS: match = re.search(pattern, msg, re.IGNORECASE) if match: - raw_item = match.group(1).strip() if match.lastindex else "" - # Extract item name: take first 1-3 non-filler words before any preposition - filler = {"a", "an", "some", "me", "the", "this", "that", "please"} - stop_words = {"with", "from", "using", "in", "on", "at", "for", "out", "of"} + raw_item = match.group(match.lastindex).strip() if match.lastindex else "" + # Extract quantity and item name + filler = {"some", "me", "the", "this", "that", "please", "of"} + stop_words = {"with", "from", "using", "in", "on", "at", "for", "out"} words = [] + count = 1 for w in raw_item.split(): wl = w.lower().rstrip(".,!?") if wl in filler: continue if wl in stop_words: 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) if len(words) >= 3: break @@ -185,6 +206,7 @@ class CommandParser: return ParsedCommand( action="craft", target=item, + params={"count": count}, raw_message=msg, ) return None @@ -284,13 +306,14 @@ def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]: ) elif cmd.action == "craft": + count = cmd.params.get("count", 1) if cmd.params else 1 return Task( name=f"craft_{cmd.target}", priority=Priority.HIGH, action="craft_item", - params={"itemName": cmd.target, "count": 1}, - description=f"Crafting {cmd.target.replace('_', ' ')}", - timeout=15, + params={"itemName": cmd.target, "count": count}, + description=f"Crafting {count}x {cmd.target.replace('_', ' ')}", + timeout=30, ) elif cmd.action == "mine": diff --git a/dougbot/gui/main_window.py b/dougbot/gui/main_window.py index 6cb4085..3d3a180 100644 --- a/dougbot/gui/main_window.py +++ b/dougbot/gui/main_window.py @@ -156,6 +156,24 @@ class MainWindow(QMainWindow): 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 ws_port = self._ws_port_counter self._ws_port_counter += 1 @@ -232,6 +250,7 @@ class MainWindow(QMainWindow): self._node_manager = None if self._ollama: + self._ollama.stop_keepalive() self._ollama.close() self._ollama = None @@ -476,9 +495,8 @@ class MainWindow(QMainWindow): return False def _generate_response(self, sender: str, message: str): - """Generate an AI response to a chat message.""" - import time as _time - import httpx + """Generate an AI response to a chat message using OllamaClient.""" + import threading if not self._active_doug: return @@ -490,6 +508,10 @@ class MainWindow(QMainWindow): self.dashboard.log_viewer.append_error("No Ollama model configured!") 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]})") # Build context from brain state @@ -512,51 +534,34 @@ class MainWindow(QMainWindow): custom_notes=doug.custom_notes, ) - # Prepare messages - messages = [{"role": "system", "content": system_prompt}] - # Add last few chat messages for context + # Build chat history from recent messages + chat_history = [] recent = self._chat_repo.get_recent(doug.id, limit=3) for msg in reversed(recent): role = "assistant" if msg["sender"] == doug.name else "user" - messages.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"}) - messages.append({"role": "user", "content": f"{sender}: {message}"}) + chat_history.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"}) - ollama_url = self.config.ollama_url + user_message = f"{sender}: {message}" doug_id = doug.id doug_name = doug.name - - # Direct HTTP call in a thread — same approach as terminal test - import threading + ollama = self._ollama def _do_request(): - t0 = _time.time() - try: - client = httpx.Client(timeout=30.0) - resp = client.post(f"{ollama_url}/api/chat", json={ - "model": model, - "messages": messages, - "stream": False, - "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() + reply = ollama.chat( + model=model, + system_prompt=system_prompt, + user_message=user_message, + chat_history=chat_history, + temperature=0.8, + num_predict=25, + ) - elapsed = _time.time() - t0 - log.info(f"Ollama responded in {elapsed:.1f}s: {reply[:60]}") + if reply: + self._chat_response_ready.emit(reply, doug_id, doug_name) + else: + log.error("Ollama returned no response (after retry)") - if reply: - # 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 = threading.Thread(target=_do_request, daemon=True, name="ollama-chat") thread.start() def _send_chat_response(self, response: str, doug_id: int, doug_name: str): diff --git a/test-combat.js b/test-combat.js new file mode 100644 index 0000000..5b3572d --- /dev/null +++ b/test-combat.js @@ -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); +}); diff --git a/test-craft.js b/test-craft.js new file mode 100644 index 0000000..77aaea0 --- /dev/null +++ b/test-craft.js @@ -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)); +}