Major overhaul: combat fix, smart brain, Ollama stability, crafting
COMBAT:
- Fixed attack serialization: proper heldItemToNotch() for empty hand
- Attack now looks at target before swinging (server validates aim)
- Player position includes eye height in attack packet
- Click position calculated relative to target entity
INTELLIGENCE (complete rewrite):
- NeedsSystem: Sims-like needs (safety, hunger, social, shelter, boredom)
that decay over time and drive behavior priorities
- GoalManager: Long-term goals broken into steps (gather_wood,
explore_area, find_food, check_container, go_home)
- SpatialMemory: Remembers locations of containers, crafting tables,
interesting blocks, and home position
- DailyRoutine: Morning/day/evening/night phases with trait-influenced
activity selection
- Brain now WAITS for tasks to complete instead of piling on new ones
- Goal-based decision making replaces random wander
OLLAMA:
- Pre-warm model on deploy (loads into GPU before first chat)
- Keep-alive pings every 2 minutes (prevents model unload)
- Adaptive timeouts: 60s cold, 15s warm, 90s retry
- Auto-retry on timeout failure
CRAFTING:
- Quantity parsing ("craft 5 sticks", "craft a wooden pickaxe")
- Number words supported ("craft three planks")
- Smarter item name extraction with preposition stopping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7199acf149
commit
195ef2d860
9 changed files with 2348 additions and 592 deletions
|
|
@ -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')) {
|
||||
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(); // in inventory
|
||||
bot.swingArm();
|
||||
}
|
||||
attackEntity(target);
|
||||
} else {
|
||||
attackEntity(target);
|
||||
if (swing) {
|
||||
bot.swingArm(); // in inventory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fetchEntity(id: any) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
// Step 2: Walk to crafting table if found but not close enough
|
||||
if (craftingTable) {
|
||||
if (!craftingTable) {
|
||||
return {
|
||||
crafted: false,
|
||||
error: `I need a crafting table to make ${friendlyName}, but I can't find one nearby.`,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (d <= 3.5) { clearInterval(check); resolve(); }
|
||||
}, 400);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 20000);
|
||||
});
|
||||
// Re-find the table now that we're close
|
||||
}
|
||||
|
||||
// Re-find the table now that we're close (block reference may have changed)
|
||||
craftingTable = bot.findBlock({
|
||||
matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'),
|
||||
maxDistance: 4,
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Craft!
|
||||
// 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 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}` };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
try:
|
||||
response = self._client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False, # Disable thinking mode (Qwen3.5, etc.)
|
||||
"think": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": 150, # Cap response length for chat
|
||||
},
|
||||
"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=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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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._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:
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
elapsed = _time.time() - t0
|
||||
log.info(f"Ollama responded in {elapsed:.1f}s: {reply[:60]}")
|
||||
reply = ollama.chat(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
chat_history=chat_history,
|
||||
temperature=0.8,
|
||||
num_predict=25,
|
||||
)
|
||||
|
||||
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")
|
||||
log.error("Ollama returned no response (after retry)")
|
||||
|
||||
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):
|
||||
|
|
|
|||
425
test-combat.js
Normal file
425
test-combat.js
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* Combat Test Script
|
||||
*
|
||||
* Connects to a Bedrock BDS server and attempts to attack the nearest mob
|
||||
* using multiple methods to determine which one actually deals damage.
|
||||
*
|
||||
* Usage:
|
||||
* node --experimental-strip-types --disable-warning=ExperimentalWarning test-combat.js
|
||||
*
|
||||
* Run from the bridge/ directory so node_modules resolve correctly.
|
||||
*/
|
||||
|
||||
// --- Ensure bridge/node_modules is on the require path ---
|
||||
const path = require('path');
|
||||
const Module = require('module');
|
||||
const bridgeDir = path.join(__dirname, 'bridge');
|
||||
const originalResolvePaths = Module._nodeModulePaths;
|
||||
Module._nodeModulePaths = function(from) {
|
||||
const paths = originalResolvePaths.call(this, from);
|
||||
const bridgeNodeModules = path.join(bridgeDir, 'node_modules');
|
||||
if (!paths.includes(bridgeNodeModules)) {
|
||||
paths.unshift(bridgeNodeModules);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
// --- Patch prismarine-physics (same as bridge/src/index.js) ---
|
||||
const origPhysicsPath = require.resolve('prismarine-physics');
|
||||
const origPhysics = require('prismarine-physics');
|
||||
const { Physics: OrigPhysics, PlayerState } = origPhysics;
|
||||
|
||||
function PatchedPhysics(mcData, world) {
|
||||
if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) {
|
||||
const attrs = [
|
||||
{ name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 },
|
||||
{ name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 },
|
||||
{ name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 },
|
||||
{ name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 },
|
||||
{ name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 },
|
||||
{ name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 },
|
||||
{ name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 },
|
||||
{ name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 },
|
||||
{ name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 },
|
||||
];
|
||||
mcData.attributesArray = attrs;
|
||||
mcData.attributes = {};
|
||||
mcData.attributesByName = {};
|
||||
for (const attr of attrs) {
|
||||
mcData.attributes[attr.resource] = attr;
|
||||
mcData.attributesByName[attr.name] = attr;
|
||||
}
|
||||
}
|
||||
return OrigPhysics(mcData, world);
|
||||
}
|
||||
require.cache[origPhysicsPath] = {
|
||||
id: origPhysicsPath,
|
||||
exports: { Physics: PatchedPhysics, PlayerState },
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
const mineflayer = require('./bridge/lib/mineflayer');
|
||||
const { pathfinder: pathfinderPlugin, Movements, goals } = require('mineflayer-pathfinder');
|
||||
const { GoalNear, GoalFollow } = goals;
|
||||
const { Vec3 } = require('vec3');
|
||||
|
||||
const HOST = '192.168.1.90';
|
||||
const PORT = 19140;
|
||||
const USERNAME = 'CombatTest';
|
||||
|
||||
const NAMED_ENTITY_HEIGHT = 1.62;
|
||||
|
||||
console.log(`[CombatTest] Connecting to ${HOST}:${PORT} as ${USERNAME}...`);
|
||||
|
||||
const bot = mineflayer.createBot({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
username: USERNAME,
|
||||
version: 'bedrock_1.21.130',
|
||||
bedrockProtocolVersion: '26.10',
|
||||
auth: 'offline',
|
||||
offline: true,
|
||||
raknetBackend: 'jsp-raknet',
|
||||
respawn: true,
|
||||
});
|
||||
|
||||
bot.loadPlugin(pathfinderPlugin);
|
||||
|
||||
// Track entity health changes via update_attributes
|
||||
const entityHealthTracker = new Map();
|
||||
bot._client.on('update_attributes', (packet) => {
|
||||
for (const attr of packet.attributes) {
|
||||
if (attr.name === 'minecraft:health') {
|
||||
const prev = entityHealthTracker.get(packet.runtime_entity_id);
|
||||
entityHealthTracker.set(packet.runtime_entity_id, attr.current);
|
||||
if (prev !== undefined && attr.current < prev) {
|
||||
console.log(`[HealthTracker] Entity ${packet.runtime_entity_id} took damage: ${prev} -> ${attr.current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Track entity_event for hurt animations
|
||||
bot._client.on('entity_event', (packet) => {
|
||||
if (packet.event_id === 'hurt_animation' || packet.event_id === 2 || packet.event_id === 'hurt') {
|
||||
console.log(`[EntityEvent] Entity ${packet.runtime_entity_id} received hurt event (event_id=${packet.event_id})`);
|
||||
}
|
||||
});
|
||||
|
||||
// Track animate packets for server-side swing confirmation
|
||||
bot._client.on('animate', (packet) => {
|
||||
if (packet.runtime_entity_id === bot.entity?.id) {
|
||||
console.log(`[Animate] Server acknowledged arm swing (action_id=${packet.action_id})`);
|
||||
}
|
||||
});
|
||||
|
||||
let spawned = false;
|
||||
let movements = null;
|
||||
|
||||
bot.once('spawn', async () => {
|
||||
spawned = true;
|
||||
movements = new Movements(bot);
|
||||
movements.canDig = false;
|
||||
movements.canOpenDoors = false;
|
||||
bot.pathfinder.setMovements(movements);
|
||||
|
||||
const pos = bot.entity.position;
|
||||
console.log(`[CombatTest] Spawned at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
|
||||
console.log(`[CombatTest] Entity ID: ${bot.entity.id}`);
|
||||
console.log(`[CombatTest] quickBarSlot: ${bot.quickBarSlot}`);
|
||||
console.log(`[CombatTest] heldItem: ${JSON.stringify(bot.heldItem)}`);
|
||||
|
||||
// Wait a moment for entities to load
|
||||
console.log('[CombatTest] Waiting 3s for entities to populate...');
|
||||
await sleep(3000);
|
||||
|
||||
// Find nearest mob
|
||||
const target = bot.nearestEntity((e) => e.type === 'mob' || (e.type === 'object' && e.name !== 'unknown' && e.name !== 'item'));
|
||||
if (!target) {
|
||||
console.log('[CombatTest] No mobs found nearby! Listing all entities:');
|
||||
for (const [id, e] of Object.entries(bot.entities)) {
|
||||
if (e === bot.entity) continue;
|
||||
console.log(` Entity ${id}: type=${e.type} name=${e.name} pos=${e.position}`);
|
||||
}
|
||||
console.log('[CombatTest] Try spawning mobs near the bot with /summon');
|
||||
// Keep running so we can try later
|
||||
waitForMobAndTest();
|
||||
return;
|
||||
}
|
||||
|
||||
await runCombatTests(target);
|
||||
});
|
||||
|
||||
async function waitForMobAndTest() {
|
||||
console.log('[CombatTest] Polling for mobs every 5s...');
|
||||
const interval = setInterval(async () => {
|
||||
const target = bot.nearestEntity((e) => e.type === 'mob' || (e.type === 'object' && e.name !== 'unknown' && e.name !== 'item'));
|
||||
if (target) {
|
||||
clearInterval(interval);
|
||||
await runCombatTests(target);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function runCombatTests(target) {
|
||||
console.log(`\n[CombatTest] ====== TARGET FOUND ======`);
|
||||
console.log(`[CombatTest] Target: ${target.name} (id=${target.id}, type=${target.type})`);
|
||||
console.log(`[CombatTest] Target position: ${target.position}`);
|
||||
console.log(`[CombatTest] Target height: ${target.height}`);
|
||||
const dist = bot.entity.position.distanceTo(target.position);
|
||||
console.log(`[CombatTest] Distance: ${dist.toFixed(2)}`);
|
||||
|
||||
// Walk towards the target if too far
|
||||
if (dist > 3.5) {
|
||||
console.log(`[CombatTest] Walking to target...`);
|
||||
try {
|
||||
bot.pathfinder.setGoal(new GoalNear(target.position.x, target.position.y, target.position.z, 2));
|
||||
await sleep(5000);
|
||||
} catch (e) {
|
||||
console.log(`[CombatTest] Pathfinding issue: ${e.message}`);
|
||||
}
|
||||
bot.pathfinder.setGoal(null);
|
||||
}
|
||||
|
||||
const finalDist = bot.entity.position.distanceTo(target.position);
|
||||
console.log(`[CombatTest] Final distance: ${finalDist.toFixed(2)}`);
|
||||
|
||||
if (finalDist > 6) {
|
||||
console.log('[CombatTest] Still too far. Teleport closer or summon a mob near the bot.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Item helpers
|
||||
const Item = require('prismarine-item')(bot.registry);
|
||||
function getHeldItemNotch() {
|
||||
const item = bot.heldItem;
|
||||
if (!item) return { network_id: 0 };
|
||||
return Item.toNotch(item, 0);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// METHOD 1: inventory_transaction with item_use_on_entity (FIXED VERSION)
|
||||
// This is the standard Bedrock protocol way to attack entities.
|
||||
// ========================================================================
|
||||
console.log(`\n[CombatTest] --- METHOD 1: inventory_transaction (fixed) ---`);
|
||||
{
|
||||
// Look at the target first
|
||||
const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0);
|
||||
await bot.lookAt(targetEyePos, true);
|
||||
await sleep(100);
|
||||
|
||||
// Swing arm first
|
||||
bot.swingArm();
|
||||
|
||||
const heldItemNotch = getHeldItemNotch();
|
||||
const playerPos = {
|
||||
x: bot.entity.position.x,
|
||||
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
|
||||
z: bot.entity.position.z,
|
||||
};
|
||||
const clickPos = {
|
||||
x: target.position.x - bot.entity.position.x,
|
||||
y: (target.position.y + (target.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
|
||||
z: target.position.z - bot.entity.position.z,
|
||||
};
|
||||
|
||||
const packet = {
|
||||
transaction: {
|
||||
legacy: { legacy_request_id: 0 },
|
||||
transaction_type: 'item_use_on_entity',
|
||||
actions: [],
|
||||
transaction_data: {
|
||||
entity_runtime_id: target.id,
|
||||
action_type: 'attack',
|
||||
hotbar_slot: bot.quickBarSlot ?? 0,
|
||||
held_item: heldItemNotch,
|
||||
player_pos: playerPos,
|
||||
click_pos: clickPos,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`[CombatTest] Sending inventory_transaction:`, JSON.stringify(packet, null, 2));
|
||||
bot._client.write('inventory_transaction', packet);
|
||||
console.log('[CombatTest] Sent! Waiting 2s to observe result...');
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// METHOD 2: bot.attack() using the mineflayer API (uses the updated code)
|
||||
// ========================================================================
|
||||
console.log(`\n[CombatTest] --- METHOD 2: bot.attack() (mineflayer API) ---`);
|
||||
{
|
||||
// Re-find the target in case it moved
|
||||
const currentTarget = bot.entities[target.id];
|
||||
if (currentTarget) {
|
||||
console.log(`[CombatTest] Calling bot.attack(target)...`);
|
||||
await bot.attack(currentTarget);
|
||||
console.log('[CombatTest] Sent! Waiting 2s to observe result...');
|
||||
await sleep(2000);
|
||||
} else {
|
||||
console.log('[CombatTest] Target entity no longer exists');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// METHOD 3: player_action with missed_swing (tests if server needs this)
|
||||
// ========================================================================
|
||||
console.log(`\n[CombatTest] --- METHOD 3: player_action missed_swing ---`);
|
||||
{
|
||||
// Look at target
|
||||
const targetEyePos = target.position.offset(0, (target.height || 0) * 0.5, 0);
|
||||
await bot.lookAt(targetEyePos, true);
|
||||
|
||||
const actionPacket = {
|
||||
runtime_entity_id: bot.entity.id,
|
||||
action: 'missed_swing',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
result_position: { x: 0, y: 0, z: 0 },
|
||||
face: 0,
|
||||
};
|
||||
console.log(`[CombatTest] Sending player_action (missed_swing)...`);
|
||||
try {
|
||||
bot._client.write('player_action', actionPacket);
|
||||
console.log('[CombatTest] Sent (this should NOT deal damage by itself).');
|
||||
} catch (e) {
|
||||
console.log(`[CombatTest] Error: ${e.message}`);
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// METHOD 4: Rapid attack combo (swing + transaction, 5 times, 500ms apart)
|
||||
// Tests if cooldown or timing matters
|
||||
// ========================================================================
|
||||
console.log(`\n[CombatTest] --- METHOD 4: Rapid attack combo (5x, 500ms apart) ---`);
|
||||
{
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const currentTarget = bot.entities[target.id];
|
||||
if (!currentTarget) {
|
||||
console.log(`[CombatTest] Target died or despawned after ${i} hits!`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Look at target
|
||||
const targetEyePos = currentTarget.position.offset(0, (currentTarget.height || 0) * 0.5, 0);
|
||||
await bot.lookAt(targetEyePos, true);
|
||||
|
||||
// Swing arm
|
||||
bot.swingArm();
|
||||
|
||||
// Send attack
|
||||
const heldItemNotch = getHeldItemNotch();
|
||||
bot._client.write('inventory_transaction', {
|
||||
transaction: {
|
||||
legacy: { legacy_request_id: 0 },
|
||||
transaction_type: 'item_use_on_entity',
|
||||
actions: [],
|
||||
transaction_data: {
|
||||
entity_runtime_id: currentTarget.id,
|
||||
action_type: 'attack',
|
||||
hotbar_slot: bot.quickBarSlot ?? 0,
|
||||
held_item: heldItemNotch,
|
||||
player_pos: {
|
||||
x: bot.entity.position.x,
|
||||
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
|
||||
z: bot.entity.position.z,
|
||||
},
|
||||
click_pos: {
|
||||
x: currentTarget.position.x - bot.entity.position.x,
|
||||
y: (currentTarget.position.y + (currentTarget.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
|
||||
z: currentTarget.position.z - bot.entity.position.z,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[CombatTest] Attack ${i + 1}/5 sent`);
|
||||
await sleep(500);
|
||||
}
|
||||
console.log('[CombatTest] Waiting 2s to observe results...');
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// METHOD 5: interact packet (mouse_over_entity) before attack
|
||||
// Some servers need the interact packet first to register targeting
|
||||
// ========================================================================
|
||||
console.log(`\n[CombatTest] --- METHOD 5: interact (mouse_over) + attack ---`);
|
||||
{
|
||||
const currentTarget = bot.entities[target.id];
|
||||
if (currentTarget) {
|
||||
// Look at target
|
||||
const targetEyePos = currentTarget.position.offset(0, (currentTarget.height || 0) * 0.5, 0);
|
||||
await bot.lookAt(targetEyePos, true);
|
||||
|
||||
// Send interact mouse_over_entity first
|
||||
try {
|
||||
bot._client.write('interact', {
|
||||
action_id: 'mouse_over_entity',
|
||||
target_entity_id: currentTarget.id,
|
||||
has_position: false,
|
||||
});
|
||||
console.log('[CombatTest] Sent interact mouse_over_entity');
|
||||
} catch (e) {
|
||||
console.log(`[CombatTest] interact packet error: ${e.message}`);
|
||||
}
|
||||
await sleep(100);
|
||||
|
||||
// Then swing and attack
|
||||
bot.swingArm();
|
||||
const heldItemNotch = getHeldItemNotch();
|
||||
bot._client.write('inventory_transaction', {
|
||||
transaction: {
|
||||
legacy: { legacy_request_id: 0 },
|
||||
transaction_type: 'item_use_on_entity',
|
||||
actions: [],
|
||||
transaction_data: {
|
||||
entity_runtime_id: currentTarget.id,
|
||||
action_type: 'attack',
|
||||
hotbar_slot: bot.quickBarSlot ?? 0,
|
||||
held_item: heldItemNotch,
|
||||
player_pos: {
|
||||
x: bot.entity.position.x,
|
||||
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
|
||||
z: bot.entity.position.z,
|
||||
},
|
||||
click_pos: {
|
||||
x: currentTarget.position.x - bot.entity.position.x,
|
||||
y: (currentTarget.position.y + (currentTarget.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
|
||||
z: currentTarget.position.z - bot.entity.position.z,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('[CombatTest] Sent attack after interact. Waiting 2s...');
|
||||
await sleep(2000);
|
||||
} else {
|
||||
console.log('[CombatTest] Target no longer exists');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n[CombatTest] ====== ALL TESTS COMPLETE ======');
|
||||
console.log('[CombatTest] Check output above for:');
|
||||
console.log(' - [HealthTracker] lines = entity took damage (health decreased)');
|
||||
console.log(' - [EntityEvent] lines = hurt animation played');
|
||||
console.log(' - [Animate] lines = server acknowledged arm swing');
|
||||
console.log('[CombatTest] Bot will stay connected for manual testing. Press Ctrl+C to exit.');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
bot.on('error', (err) => {
|
||||
console.error('[CombatTest] Error:', err.message);
|
||||
});
|
||||
|
||||
bot.on('end', (reason) => {
|
||||
console.log('[CombatTest] Disconnected:', reason);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
bot.on('kicked', (reason) => {
|
||||
console.log('[CombatTest] Kicked:', reason);
|
||||
});
|
||||
275
test-craft.js
Normal file
275
test-craft.js
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* Test script for DougBot crafting — connects to BDS and tests crafting sticks.
|
||||
*
|
||||
* Usage:
|
||||
* node --experimental-strip-types --disable-warning=ExperimentalWarning test-craft.js
|
||||
*
|
||||
* Or with full path:
|
||||
* /Users/roberts/.local/share/fnm/node-versions/v22.22.2/installation/bin/node \
|
||||
* --experimental-strip-types --disable-warning=ExperimentalWarning test-craft.js
|
||||
*/
|
||||
|
||||
// --- Patch prismarine-physics for missing bedrock attributes ---
|
||||
const origPhysicsPath = require.resolve('prismarine-physics');
|
||||
const origPhysics = require('prismarine-physics');
|
||||
const { Physics: OrigPhysics, PlayerState } = origPhysics;
|
||||
|
||||
function PatchedPhysics(mcData, world) {
|
||||
if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) {
|
||||
const attrs = [
|
||||
{ name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 },
|
||||
{ name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 },
|
||||
{ name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 },
|
||||
{ name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 },
|
||||
{ name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 },
|
||||
{ name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 },
|
||||
{ name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 },
|
||||
{ name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 },
|
||||
{ name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 },
|
||||
];
|
||||
mcData.attributesArray = attrs;
|
||||
mcData.attributes = {};
|
||||
mcData.attributesByName = {};
|
||||
for (const attr of attrs) {
|
||||
mcData.attributes[attr.resource] = attr;
|
||||
mcData.attributesByName[attr.name] = attr;
|
||||
}
|
||||
}
|
||||
return OrigPhysics(mcData, world);
|
||||
}
|
||||
require.cache[origPhysicsPath] = {
|
||||
id: origPhysicsPath,
|
||||
exports: { Physics: PatchedPhysics, PlayerState },
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
// --- Imports ---
|
||||
const path = require('path');
|
||||
const mineflayer = require(path.join(__dirname, 'bridge', 'lib', 'mineflayer'));
|
||||
const { pathfinder: pathfinderPlugin, Movements, goals } = require('mineflayer-pathfinder');
|
||||
const { GoalNear } = goals;
|
||||
|
||||
// --- Config ---
|
||||
const HOST = process.env.BDS_HOST || '192.168.1.90';
|
||||
const PORT = parseInt(process.env.BDS_PORT || '19140');
|
||||
const USERNAME = process.env.BDS_USER || 'CraftTest';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
|
||||
}
|
||||
|
||||
log(`Connecting to ${HOST}:${PORT} as ${USERNAME}...`);
|
||||
|
||||
const bot = mineflayer.createBot({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
username: USERNAME,
|
||||
version: 'bedrock_1.21.130',
|
||||
bedrockProtocolVersion: '26.10',
|
||||
auth: 'offline',
|
||||
offline: true,
|
||||
raknetBackend: 'jsp-raknet',
|
||||
});
|
||||
|
||||
bot.loadPlugin(pathfinderPlugin);
|
||||
|
||||
let testPhase = 'waiting';
|
||||
|
||||
bot.once('spawn', async () => {
|
||||
log('Spawned! Setting up pathfinder...');
|
||||
|
||||
const movements = new Movements(bot);
|
||||
movements.canDig = false;
|
||||
bot.pathfinder.setMovements(movements);
|
||||
|
||||
const pos = bot.entity.position;
|
||||
log(`Position: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
|
||||
|
||||
// Wait a moment for recipes to load
|
||||
await sleep(3000);
|
||||
|
||||
// --- Run test sequence ---
|
||||
try {
|
||||
await testResolveNames();
|
||||
await testInventoryCheck();
|
||||
await testCraftSticks();
|
||||
log('=== ALL TESTS COMPLETE ===');
|
||||
} catch (e) {
|
||||
log(`TEST FAILED: ${e.message}`);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Disconnect after tests
|
||||
await sleep(2000);
|
||||
log('Disconnecting...');
|
||||
bot.quit();
|
||||
setTimeout(() => process.exit(0), 1000);
|
||||
});
|
||||
|
||||
bot.on('error', (err) => {
|
||||
log(`ERROR: ${err.message}`);
|
||||
});
|
||||
|
||||
bot.on('kicked', (reason) => {
|
||||
log(`KICKED: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// --- Test: Name Resolution ---
|
||||
async function testResolveNames() {
|
||||
log('--- TEST: Item Name Resolution ---');
|
||||
const mcData = require('minecraft-data')(bot.version);
|
||||
|
||||
const testCases = [
|
||||
['sticks', 'stick'],
|
||||
['stick', 'stick'],
|
||||
['planks', 'planks'],
|
||||
['oak_planks', 'oak_planks'],
|
||||
['wooden_pickaxe', 'wooden_pickaxe'],
|
||||
['wood_pickaxe', 'wooden_pickaxe'],
|
||||
['wooden pickaxe', 'wooden_pickaxe'],
|
||||
['crafting_table', 'crafting_table'],
|
||||
['workbench', 'crafting_table'],
|
||||
['torches', 'torch'],
|
||||
['iron', 'iron_ingot'],
|
||||
['cobble', 'cobblestone'],
|
||||
['stone_sword', 'stone_sword'],
|
||||
['diamond_pickaxe', 'diamond_pickaxe'],
|
||||
];
|
||||
|
||||
// Inline version of resolveItemName for testing
|
||||
const ITEM_ALIASES = {
|
||||
'sticks': 'stick', 'planks': 'planks', 'torches': 'torch', 'logs': 'oak_log',
|
||||
'wood': 'oak_log', 'wood_planks': 'planks', 'plank': 'planks',
|
||||
'log': 'oak_log', 'cobble': 'cobblestone',
|
||||
'wood_pickaxe': 'wooden_pickaxe', 'wood_sword': 'wooden_sword',
|
||||
'wood_axe': 'wooden_axe', 'wood_shovel': 'wooden_shovel', 'wood_hoe': 'wooden_hoe',
|
||||
'workbench': 'crafting_table', 'bench': 'crafting_table',
|
||||
'iron': 'iron_ingot', 'gold': 'gold_ingot',
|
||||
};
|
||||
|
||||
function resolveItemName(rawName) {
|
||||
let name = rawName.toLowerCase().trim().replace(/\s+/g, '_');
|
||||
if (ITEM_ALIASES[name]) name = ITEM_ALIASES[name];
|
||||
let item = mcData.itemsByName[name];
|
||||
if (item) return item.name;
|
||||
if (name.endsWith('s') && !name.endsWith('ss')) {
|
||||
item = mcData.itemsByName[name.slice(0, -1)];
|
||||
if (item) return item.name;
|
||||
}
|
||||
if (name.startsWith('wooden_')) {
|
||||
item = mcData.itemsByName[name.replace('wooden_', 'wood_')];
|
||||
if (item) return item.name;
|
||||
}
|
||||
if (name.startsWith('wood_')) {
|
||||
item = mcData.itemsByName[name.replace('wood_', 'wooden_')];
|
||||
if (item) return item.name;
|
||||
}
|
||||
for (const [n] of Object.entries(mcData.itemsByName)) {
|
||||
if (n === name || n.includes(name) || name.includes(n)) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
for (const [input, expected] of testCases) {
|
||||
const result = resolveItemName(input);
|
||||
const ok = result === expected;
|
||||
log(` ${ok ? 'PASS' : 'FAIL'}: "${input}" → "${result}" (expected "${expected}")`);
|
||||
if (ok) passed++;
|
||||
}
|
||||
log(` Results: ${passed}/${testCases.length} passed`);
|
||||
}
|
||||
|
||||
// --- Test: Inventory Check ---
|
||||
async function testInventoryCheck() {
|
||||
log('--- TEST: Inventory Contents ---');
|
||||
const items = bot.inventory.items();
|
||||
if (items.length === 0) {
|
||||
log(' Inventory is EMPTY. Give CraftTest some planks to test crafting.');
|
||||
log(' Use: /give CraftTest oak_planks 4');
|
||||
} else {
|
||||
for (const item of items) {
|
||||
log(` ${item.count}x ${item.name} (id=${item.type}, slot=${item.slot})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test: Craft Sticks ---
|
||||
async function testCraftSticks() {
|
||||
log('--- TEST: Craft Sticks ---');
|
||||
|
||||
const mcData = require('minecraft-data')(bot.version);
|
||||
|
||||
// Check if we have planks
|
||||
const planks = bot.inventory.items().find(i => {
|
||||
const n = i.name.replace('minecraft:', '');
|
||||
return n === 'planks' || n.endsWith('_planks');
|
||||
});
|
||||
|
||||
if (!planks) {
|
||||
log(' SKIP: No planks in inventory. Give CraftTest planks first.');
|
||||
log(' Hint: /give CraftTest oak_planks 4');
|
||||
return;
|
||||
}
|
||||
|
||||
log(` Have ${planks.count}x ${planks.name}`);
|
||||
|
||||
// Look up stick item
|
||||
const stickItem = mcData.itemsByName['stick'];
|
||||
if (!stickItem) {
|
||||
log(' FAIL: "stick" not found in minecraft-data!');
|
||||
return;
|
||||
}
|
||||
log(` stick item id = ${stickItem.id}`);
|
||||
|
||||
// Check recipes
|
||||
const recipesNoTable = bot.recipesFor(stickItem.id, null, null, undefined);
|
||||
log(` Recipes without table: ${recipesNoTable.length}`);
|
||||
|
||||
// Find crafting table nearby
|
||||
const craftingTable = bot.findBlock({
|
||||
matching: (block) => {
|
||||
const n = block.name.replace('minecraft:', '');
|
||||
return n === 'crafting_table' || n === 'workbench';
|
||||
},
|
||||
maxDistance: 32,
|
||||
});
|
||||
|
||||
if (craftingTable) {
|
||||
log(` Found crafting table at ${craftingTable.position}`);
|
||||
const recipesWithTable = bot.recipesFor(stickItem.id, null, null, craftingTable);
|
||||
log(` Recipes with table: ${recipesWithTable.length}`);
|
||||
} else {
|
||||
log(' No crafting table found nearby');
|
||||
}
|
||||
|
||||
// Try to craft (sticks are 2x2, should work without a table)
|
||||
const recipes = recipesNoTable.length > 0 ? recipesNoTable :
|
||||
(craftingTable ? bot.recipesFor(stickItem.id, null, null, craftingTable) : []);
|
||||
|
||||
if (recipes.length === 0) {
|
||||
log(' FAIL: No recipes available for sticks');
|
||||
return;
|
||||
}
|
||||
|
||||
log(` Using recipe: ${JSON.stringify(recipes[0], null, 2).slice(0, 200)}`);
|
||||
|
||||
try {
|
||||
const table = recipesNoTable.length > 0 ? undefined : craftingTable;
|
||||
await bot.craft(recipes[0], 1, table);
|
||||
log(' SUCCESS: Crafted sticks!');
|
||||
|
||||
// Verify sticks in inventory
|
||||
const sticks = bot.inventory.items().find(i => i.name.replace('minecraft:', '') === 'stick');
|
||||
if (sticks) {
|
||||
log(` Verified: ${sticks.count}x stick in inventory`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(` FAIL: Craft error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
Loading…
Reference in a new issue