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;
|
return best;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to convert held item to wire format for packets
|
||||||
|
function heldItemToNotch() {
|
||||||
|
const item = bot.heldItem;
|
||||||
|
if (!item) return { network_id: 0 };
|
||||||
|
return Item.toNotch(item, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset list of players and entities on login
|
// Reset list of players and entities on login
|
||||||
bot._client.on('start_game', (packet) => {
|
bot._client.on('start_game', (packet) => {
|
||||||
bot.players = {};
|
bot.players = {};
|
||||||
|
|
@ -523,16 +530,22 @@ export default function inject(bot: BedrockBot) {
|
||||||
}
|
}
|
||||||
function itemUseOnEntity(target: any, type: number) {
|
function itemUseOnEntity(target: any, type: number) {
|
||||||
const typeStr = ['attack', 'interact'][type];
|
const typeStr = ['attack', 'interact'][type];
|
||||||
// Provide empty item if hand is empty to avoid serialization crash
|
// Convert held item to wire format using prismarine-item serialization
|
||||||
const heldItem = bot.heldItem || {
|
const heldItemNotch = heldItemToNotch();
|
||||||
network_id: 0,
|
// Player position must include eye height (matching player_auth_input format)
|
||||||
count: 0,
|
const playerPos = {
|
||||||
metadata: 0,
|
x: bot.entity.position.x,
|
||||||
has_stack_id: false,
|
y: bot.entity.position.y + NAMED_ENTITY_HEIGHT,
|
||||||
stack_id: 0,
|
z: bot.entity.position.z,
|
||||||
block_runtime_id: 0,
|
|
||||||
extra: { has_nbt: false, can_place_on: [], can_destroy: [] },
|
|
||||||
};
|
};
|
||||||
|
// Click position relative to the target entity
|
||||||
|
const clickPos = target.position
|
||||||
|
? {
|
||||||
|
x: target.position.x - bot.entity.position.x,
|
||||||
|
y: (target.position.y + (target.height || 0) * 0.5) - (bot.entity.position.y + NAMED_ENTITY_HEIGHT),
|
||||||
|
z: target.position.z - bot.entity.position.z,
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0, z: 0 };
|
||||||
const transaction = {
|
const transaction = {
|
||||||
transaction: {
|
transaction: {
|
||||||
legacy: {
|
legacy: {
|
||||||
|
|
@ -543,33 +556,27 @@ export default function inject(bot: BedrockBot) {
|
||||||
transaction_data: {
|
transaction_data: {
|
||||||
entity_runtime_id: target.id,
|
entity_runtime_id: target.id,
|
||||||
action_type: typeStr,
|
action_type: typeStr,
|
||||||
hotbar_slot: bot.quickBarSlot,
|
hotbar_slot: bot.quickBarSlot ?? 0,
|
||||||
held_item: heldItem,
|
held_item: heldItemNotch,
|
||||||
player_pos: bot.entity.position,
|
player_pos: playerPos,
|
||||||
click_pos: {
|
click_pos: clickPos,
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
z: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
bot._client.write('inventory_transaction', transaction);
|
bot._client.write('inventory_transaction', transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attack(target: any, swing = true) {
|
async function attack(target: any, swing = true) {
|
||||||
// arm animation comes before the use_entity packet on 1.8
|
// Look at the target entity before attacking (server validates player aim)
|
||||||
if (bot.supportFeature('armAnimationBeforeUse')) {
|
if (target.position) {
|
||||||
|
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) {
|
if (swing) {
|
||||||
bot.swingArm(); // in inventory
|
bot.swingArm();
|
||||||
}
|
}
|
||||||
attackEntity(target);
|
attackEntity(target);
|
||||||
} else {
|
|
||||||
attackEntity(target);
|
|
||||||
if (swing) {
|
|
||||||
bot.swingArm(); // in inventory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchEntity(id: any) {
|
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 ---
|
// --- Hostile mob list ---
|
||||||
const HOSTILE_MOBS = new Set([
|
const HOSTILE_MOBS = new Set([
|
||||||
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider',
|
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:spider',
|
||||||
|
|
@ -634,95 +817,132 @@ async function handleAction(action, params = {}) {
|
||||||
case 'list_recipes': {
|
case 'list_recipes': {
|
||||||
const { itemName } = params;
|
const { itemName } = params;
|
||||||
const mcData = require('minecraft-data')(bot.version);
|
const mcData = require('minecraft-data')(bot.version);
|
||||||
const item = mcData.itemsByName[itemName];
|
const resolved = resolveItemName(itemName, mcData);
|
||||||
if (!item) return { recipes: [], error: `Unknown item: ${itemName}` };
|
if (!resolved) return { recipes: [], error: `Unknown item: ${itemName}` };
|
||||||
const recipes = bot.recipesFor(item.id);
|
const recipes = bot.recipesFor(resolved.id);
|
||||||
return {
|
return {
|
||||||
recipes: recipes.map((r, i) => ({
|
recipes: recipes.map((r, i) => ({
|
||||||
index: i,
|
index: i,
|
||||||
ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [],
|
ingredients: r.ingredients?.map(ing => ing?.name || 'unknown') || [],
|
||||||
})),
|
})),
|
||||||
|
resolvedName: resolved.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'craft_item': {
|
case 'craft_item': {
|
||||||
const { itemName, count: craftCount } = params;
|
const { itemName, count: craftCount } = params;
|
||||||
const mcData = require('minecraft-data')(bot.version);
|
const mcData = require('minecraft-data')(bot.version);
|
||||||
|
const wantCount = craftCount || 1;
|
||||||
|
|
||||||
// Try to find item — handle both "wooden_pickaxe" and "planks" style names
|
// Step 1: Resolve the item name to a real Bedrock item ID
|
||||||
let item = mcData.itemsByName[itemName];
|
const resolved = resolveItemName(itemName, mcData);
|
||||||
if (!item) {
|
if (!resolved) {
|
||||||
// Try common Bedrock name variants
|
|
||||||
const variants = [
|
|
||||||
itemName,
|
|
||||||
itemName.replace('wooden_', 'wood_'),
|
|
||||||
itemName.replace('wood_', 'wooden_'),
|
|
||||||
`minecraft:${itemName}`,
|
|
||||||
itemName.replace('_', ''),
|
|
||||||
];
|
|
||||||
for (const v of variants) {
|
|
||||||
item = mcData.itemsByName[v];
|
|
||||||
if (item) break;
|
|
||||||
}
|
|
||||||
// Try fuzzy match
|
|
||||||
if (!item) {
|
|
||||||
for (const [name, data] of Object.entries(mcData.itemsByName)) {
|
|
||||||
if (name.includes(itemName) || itemName.includes(name)) {
|
|
||||||
item = data;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!item) {
|
|
||||||
return { crafted: false, error: `I don't know what "${itemName.replace(/_/g, ' ')}" is.` };
|
return { crafted: false, error: `I don't know what "${itemName.replace(/_/g, ' ')}" is.` };
|
||||||
}
|
}
|
||||||
|
const realName = resolved.name;
|
||||||
|
const friendlyName = realName.replace(/_/g, ' ');
|
||||||
|
log('client', 'INFO', `Craft request: "${itemName}" → resolved to "${realName}" (id=${resolved.id})`);
|
||||||
|
|
||||||
// Step 1: Find crafting table (search wider radius)
|
// Step 2: Check inventory for required materials BEFORE doing anything
|
||||||
let craftingTable = bot.findBlock({
|
const materialCheck = checkCraftingMaterials(bot, realName, wantCount);
|
||||||
matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'),
|
if (materialCheck && !materialCheck.canCraft) {
|
||||||
maxDistance: 32,
|
const missingList = Object.entries(materialCheck.missing)
|
||||||
|
.map(([mat, qty]) => `${qty}x ${mat.replace(/_/g, ' ')}`)
|
||||||
|
.join(', ');
|
||||||
|
return {
|
||||||
|
crafted: false,
|
||||||
|
error: `I need ${missingList} to craft ${friendlyName}, but I don't have enough.`,
|
||||||
|
missing: materialCheck.missing,
|
||||||
|
have: materialCheck.have,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Determine if a crafting table is needed
|
||||||
|
const needsTable = materialCheck ? materialCheck.needsTable : true; // Assume table needed if unknown recipe
|
||||||
|
|
||||||
|
// Step 4: Find and walk to crafting table if needed
|
||||||
|
let craftingTable = null;
|
||||||
|
if (needsTable) {
|
||||||
|
craftingTable = bot.findBlock({
|
||||||
|
matching: (block) => {
|
||||||
|
const n = block.name.replace('minecraft:', '');
|
||||||
|
return n === 'crafting_table' || n === 'workbench';
|
||||||
|
},
|
||||||
|
maxDistance: 48,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
const dist = bot.entity.position.distanceTo(craftingTable.position);
|
||||||
if (dist > 4) {
|
if (dist > 3.5) {
|
||||||
log('client', 'INFO', `Walking to crafting table at ${craftingTable.position}`);
|
log('client', 'INFO', `Walking to crafting table at ${craftingTable.position} (dist=${dist.toFixed(1)})`);
|
||||||
bot.pathfinder.setGoal(new GoalNear(
|
bot.pathfinder.setGoal(new GoalNear(
|
||||||
craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 2
|
craftingTable.position.x, craftingTable.position.y, craftingTable.position.z, 2
|
||||||
));
|
));
|
||||||
// Wait for arrival (up to 15 seconds)
|
// Wait for arrival (up to 20 seconds)
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const check = setInterval(() => {
|
const check = setInterval(() => {
|
||||||
const d = bot.entity.position.distanceTo(craftingTable.position);
|
const d = bot.entity.position.distanceTo(craftingTable.position);
|
||||||
if (d <= 3) { clearInterval(check); resolve(); }
|
if (d <= 3.5) { clearInterval(check); resolve(); }
|
||||||
}, 500);
|
}, 400);
|
||||||
setTimeout(() => { clearInterval(check); resolve(); }, 15000);
|
setTimeout(() => { clearInterval(check); resolve(); }, 20000);
|
||||||
});
|
});
|
||||||
// Re-find the table now that we're close
|
}
|
||||||
|
|
||||||
|
// Re-find the table now that we're close (block reference may have changed)
|
||||||
craftingTable = bot.findBlock({
|
craftingTable = bot.findBlock({
|
||||||
matching: (block) => block.name.includes('crafting_table') || block.name.includes('workbench'),
|
matching: (block) => {
|
||||||
maxDistance: 4,
|
const n = block.name.replace('minecraft:', '');
|
||||||
|
return n === 'crafting_table' || n === 'workbench';
|
||||||
|
},
|
||||||
|
maxDistance: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!craftingTable) {
|
||||||
|
return {
|
||||||
|
crafted: false,
|
||||||
|
error: `I walked to the crafting table but can't reach it. Is it blocked?`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Check recipes
|
// Step 5: Look up recipes from mineflayer
|
||||||
const recipes = bot.recipesFor(item.id, null, null, craftingTable || undefined);
|
const recipes = bot.recipesFor(resolved.id, null, null, craftingTable || undefined);
|
||||||
if (recipes.length === 0) {
|
if (recipes.length === 0) {
|
||||||
const reason = craftingTable
|
// Try without table in case mineflayer knows it as a 2x2 recipe
|
||||||
? `I don't have the materials to craft ${itemName.replace(/_/g, ' ')}.`
|
const recipes2x2 = bot.recipesFor(resolved.id, null, null, undefined);
|
||||||
: `I need a crafting table to make ${itemName.replace(/_/g, ' ')}.`;
|
if (recipes2x2.length > 0) {
|
||||||
return { crafted: false, error: reason };
|
// It's a 2x2 recipe, no table needed after all
|
||||||
|
try {
|
||||||
|
await bot.craft(recipes2x2[0], wantCount, undefined);
|
||||||
|
log('client', 'INFO', `Crafted ${wantCount}x ${realName} (2x2, no table)`);
|
||||||
|
return { crafted: true, item: realName, count: wantCount };
|
||||||
|
} catch (e) {
|
||||||
|
return { crafted: false, error: `Crafting failed: ${e.message}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
await bot.craft(recipes[0], craftCount || 1, craftingTable || undefined);
|
await bot.craft(recipes[0], wantCount, craftingTable || undefined);
|
||||||
log('client', 'INFO', `Crafted ${craftCount || 1}x ${itemName}`);
|
log('client', 'INFO', `Crafted ${wantCount}x ${realName}`);
|
||||||
return { crafted: true, item: itemName, count: craftCount || 1 };
|
return { crafted: true, item: realName, count: wantCount };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
log('client', 'ERROR', `Craft failed: ${e.message}`);
|
||||||
return { crafted: false, error: `Crafting failed: ${e.message}` };
|
return { crafted: false, error: `Crafting failed: ${e.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ Ollama API client.
|
||||||
Handles communication with the local Ollama server for AI reasoning.
|
Handles communication with the local Ollama server for AI reasoning.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional, AsyncGenerator
|
from typing import Optional
|
||||||
from dougbot.utils.logging import get_logger
|
from dougbot.utils.logging import get_logger
|
||||||
|
|
||||||
log = get_logger("ai.ollama")
|
log = get_logger("ai.ollama")
|
||||||
|
|
@ -13,10 +15,29 @@ log = get_logger("ai.ollama")
|
||||||
class OllamaClient:
|
class OllamaClient:
|
||||||
"""HTTP client for the Ollama REST API."""
|
"""HTTP client for the Ollama REST API."""
|
||||||
|
|
||||||
|
# Timeout for first request (model may need to load into GPU)
|
||||||
|
COLD_TIMEOUT = 60.0
|
||||||
|
# Timeout for subsequent requests (model already warm)
|
||||||
|
WARM_TIMEOUT = 15.0
|
||||||
|
# Retry timeout (longer, used after a failed attempt)
|
||||||
|
RETRY_TIMEOUT = 90.0
|
||||||
|
# Keep-alive interval in seconds
|
||||||
|
KEEPALIVE_INTERVAL = 120.0
|
||||||
|
|
||||||
def __init__(self, base_url: str = "http://127.0.0.1:11434"):
|
def __init__(self, base_url: str = "http://127.0.0.1:11434"):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
# Long timeout — first request loads the model into GPU/RAM which can take minutes
|
self._model_warm = False
|
||||||
self._client = httpx.Client(timeout=300.0)
|
self._closed = False
|
||||||
|
self._keepalive_timer: Optional[threading.Timer] = None
|
||||||
|
self._keepalive_model: Optional[str] = None
|
||||||
|
# Use a shared client with the cold timeout initially
|
||||||
|
self._client = httpx.Client(timeout=self.COLD_TIMEOUT)
|
||||||
|
|
||||||
|
def _get_timeout(self) -> float:
|
||||||
|
"""Return the appropriate timeout based on model warmth."""
|
||||||
|
if self._model_warm:
|
||||||
|
return self.WARM_TIMEOUT
|
||||||
|
return self.COLD_TIMEOUT
|
||||||
|
|
||||||
def list_models(self) -> list[str]:
|
def list_models(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -37,6 +58,103 @@ class OllamaClient:
|
||||||
log.error(f"Failed to list models: {e}")
|
log.error(f"Failed to list models: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def warm_model(self, model: str) -> bool:
|
||||||
|
"""
|
||||||
|
Pre-warm a model by sending a tiny request to load it into GPU memory.
|
||||||
|
Should be called in a background thread. Returns True on success.
|
||||||
|
"""
|
||||||
|
log.info(f"Pre-warming model '{model}'...")
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
resp = self._client.post(
|
||||||
|
f"{self.base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
"stream": False,
|
||||||
|
"think": False,
|
||||||
|
"options": {"num_predict": 1},
|
||||||
|
},
|
||||||
|
timeout=self.COLD_TIMEOUT,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
self._model_warm = True
|
||||||
|
log.info(f"Model '{model}' warmed in {elapsed:.1f}s")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
log.error(f"Failed to warm model '{model}' after {elapsed:.1f}s: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def warm_model_async(self, model: str, callback=None):
|
||||||
|
"""
|
||||||
|
Pre-warm the model in a background thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Model name to warm up.
|
||||||
|
callback: Optional callable(success: bool) invoked when done.
|
||||||
|
"""
|
||||||
|
def _run():
|
||||||
|
success = self.warm_model(model)
|
||||||
|
if callback:
|
||||||
|
callback(success)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run, daemon=True, name="ollama-warmup")
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def start_keepalive(self, model: str):
|
||||||
|
"""
|
||||||
|
Start a recurring keep-alive ping that sends a tiny request every
|
||||||
|
KEEPALIVE_INTERVAL seconds to prevent the model from being unloaded.
|
||||||
|
"""
|
||||||
|
self._keepalive_model = model
|
||||||
|
self._schedule_keepalive()
|
||||||
|
log.info(f"Keep-alive started for '{model}' (every {self.KEEPALIVE_INTERVAL:.0f}s)")
|
||||||
|
|
||||||
|
def _schedule_keepalive(self):
|
||||||
|
"""Schedule the next keep-alive ping."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._keepalive_timer = threading.Timer(
|
||||||
|
self.KEEPALIVE_INTERVAL, self._keepalive_ping
|
||||||
|
)
|
||||||
|
self._keepalive_timer.daemon = True
|
||||||
|
self._keepalive_timer.start()
|
||||||
|
|
||||||
|
def _keepalive_ping(self):
|
||||||
|
"""Send a tiny request to keep the model loaded in GPU memory."""
|
||||||
|
if self._closed or not self._keepalive_model:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = self._client.post(
|
||||||
|
f"{self.base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self._keepalive_model,
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"stream": False,
|
||||||
|
"think": False,
|
||||||
|
"options": {"num_predict": 1},
|
||||||
|
},
|
||||||
|
timeout=self.COLD_TIMEOUT,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._model_warm = True
|
||||||
|
log.debug("Keep-alive ping OK")
|
||||||
|
except Exception as e:
|
||||||
|
self._model_warm = False
|
||||||
|
log.warning(f"Keep-alive ping failed: {e}")
|
||||||
|
|
||||||
|
# Schedule the next ping
|
||||||
|
self._schedule_keepalive()
|
||||||
|
|
||||||
|
def stop_keepalive(self):
|
||||||
|
"""Stop the keep-alive timer."""
|
||||||
|
if self._keepalive_timer:
|
||||||
|
self._keepalive_timer.cancel()
|
||||||
|
self._keepalive_timer = None
|
||||||
|
self._keepalive_model = None
|
||||||
|
|
||||||
def chat(
|
def chat(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
|
|
@ -44,23 +162,25 @@ class OllamaClient:
|
||||||
user_message: str,
|
user_message: str,
|
||||||
chat_history: Optional[list[dict]] = None,
|
chat_history: Optional[list[dict]] = None,
|
||||||
temperature: float = 0.8,
|
temperature: float = 0.8,
|
||||||
|
num_predict: int = 150,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Send a chat request to Ollama and return the response.
|
Send a chat request to Ollama and return the response.
|
||||||
|
Includes automatic retry with longer timeout on failure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Model name (e.g., "llama3", "mistral")
|
model: Model name (e.g., "llama3.2:3b", "qwen3.5:9b")
|
||||||
system_prompt: The system prompt (persona + context)
|
system_prompt: The system prompt (persona + context)
|
||||||
user_message: The user's message
|
user_message: The user's message
|
||||||
chat_history: Previous messages for context
|
chat_history: Previous messages for context
|
||||||
temperature: Creativity (0.0-1.0, higher = more creative)
|
temperature: Creativity (0.0-1.0, higher = more creative)
|
||||||
|
num_predict: Max tokens to generate
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The AI response text, or None on failure.
|
The AI response text, or None on failure.
|
||||||
"""
|
"""
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
# Add conversation history
|
|
||||||
if chat_history:
|
if chat_history:
|
||||||
for msg in chat_history:
|
for msg in chat_history:
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|
@ -68,48 +188,75 @@ class OllamaClient:
|
||||||
"content": msg.get("content", ""),
|
"content": msg.get("content", ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add current message
|
|
||||||
messages.append({"role": "user", "content": user_message})
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
try:
|
payload = {
|
||||||
response = self._client.post(
|
|
||||||
f"{self.base_url}/api/chat",
|
|
||||||
json={
|
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"think": False, # Disable thinking mode (Qwen3.5, etc.)
|
"think": False,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": temperature,
|
"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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
reply = data.get("message", {}).get("content", "").strip()
|
||||||
reply = data.get("message", {}).get("content", "")
|
elapsed = time.time() - t0
|
||||||
log.debug(f"Ollama response ({len(reply)} chars): {reply[:100]}...")
|
log.debug(f"Ollama response in {elapsed:.1f}s ({len(reply)} chars): {reply[:100]}")
|
||||||
return reply
|
return reply if reply else None
|
||||||
|
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
log.error(f"Cannot connect to Ollama at {self.base_url}")
|
log.error(f"Cannot connect to Ollama at {self.base_url}")
|
||||||
return None
|
return None
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
log.error("Ollama request timed out")
|
elapsed = time.time() - t0
|
||||||
|
log.error(f"Ollama request timed out after {elapsed:.1f}s (limit={timeout}s)")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Ollama chat failed: {e}")
|
elapsed = time.time() - t0
|
||||||
|
log.error(f"Ollama chat failed after {elapsed:.1f}s: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""Check if the Ollama server is reachable."""
|
"""Check if the Ollama server is reachable."""
|
||||||
try:
|
try:
|
||||||
response = self._client.get(f"{self.base_url}/api/tags")
|
response = self._client.get(f"{self.base_url}/api/tags", timeout=5.0)
|
||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the HTTP client."""
|
"""Close the HTTP client and stop background tasks."""
|
||||||
|
self._closed = True
|
||||||
|
self.stop_keepalive()
|
||||||
self._client.close()
|
self._client.close()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"""
|
"""
|
||||||
Behavior modules for Doug. Each behavior generates tasks based on
|
Behavior systems for Doug's goal-based brain.
|
||||||
world state and persona traits.
|
|
||||||
|
Modules:
|
||||||
|
- NeedsSystem: Sims-like needs (safety, hunger, social, shelter, boredom) 0-100
|
||||||
|
- GoalManager: Long-term goals broken into steps
|
||||||
|
- SpatialMemory: Remembers where things are
|
||||||
|
- DailyRoutine: Time-of-day activity phases
|
||||||
|
- BehaviorEngine: World state container (kept for compatibility)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
@ -14,11 +20,551 @@ from dougbot.utils.logging import get_logger
|
||||||
log = get_logger("core.behaviors")
|
log = get_logger("core.behaviors")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# NEEDS SYSTEM — like The Sims, values 0-100, decay over time
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class NeedsSystem:
|
||||||
|
"""
|
||||||
|
Tracks Doug's needs. Each need is 0-100.
|
||||||
|
Low values = urgent need. High values = satisfied.
|
||||||
|
Needs decay every brain tick (2s) based on conditions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, traits: dict):
|
||||||
|
self._traits = traits
|
||||||
|
|
||||||
|
# All needs start satisfied
|
||||||
|
self.safety = 100 # Threat level (low = danger nearby)
|
||||||
|
self.hunger = 80 # Food level (low = need to eat)
|
||||||
|
self.social = 60 # Interaction need (low = lonely)
|
||||||
|
self.shelter = 70 # Has safe place (low = exposed)
|
||||||
|
self.boredom = 60 # Stimulation (low = bored)
|
||||||
|
|
||||||
|
def decay(self, health: int, food: int, has_shelter: bool,
|
||||||
|
is_night: bool, has_players_nearby: bool, hostiles_nearby: int):
|
||||||
|
"""Called every brain tick (2s). Decay needs based on conditions."""
|
||||||
|
|
||||||
|
# SAFETY: based on health and threats
|
||||||
|
if hostiles_nearby > 0:
|
||||||
|
self.safety = max(0, self.safety - 8 * hostiles_nearby)
|
||||||
|
elif health < 10:
|
||||||
|
self.safety = max(0, min(self.safety, health * 5))
|
||||||
|
else:
|
||||||
|
# Slowly recover when safe
|
||||||
|
self.safety = min(100, self.safety + 3)
|
||||||
|
|
||||||
|
# HUNGER: directly tied to food bar (food is 0-20 in MC)
|
||||||
|
self.hunger = min(100, max(0, food * 5))
|
||||||
|
|
||||||
|
# SOCIAL: decays when alone, recovers near players
|
||||||
|
sociability = self._traits.get("sociability", 50)
|
||||||
|
if has_players_nearby:
|
||||||
|
self.social = min(100, self.social + 2)
|
||||||
|
else:
|
||||||
|
# Higher sociability = faster social decay (needs people more)
|
||||||
|
decay_rate = 0.3 + (sociability / 200) # 0.3 to 0.55 per tick
|
||||||
|
self.social = max(0, self.social - decay_rate)
|
||||||
|
|
||||||
|
# SHELTER: drops at night without shelter, fine during day
|
||||||
|
if is_night and not has_shelter:
|
||||||
|
anxiety = self._traits.get("anxiety", 0)
|
||||||
|
self.shelter = max(0, self.shelter - (2 + (1 if anxiety else 0)))
|
||||||
|
elif has_shelter:
|
||||||
|
self.shelter = min(100, self.shelter + 5)
|
||||||
|
else:
|
||||||
|
# Daytime without shelter is fine
|
||||||
|
self.shelter = min(100, self.shelter + 1)
|
||||||
|
|
||||||
|
# BOREDOM: always slowly decays, faster if nothing is happening
|
||||||
|
curiosity = self._traits.get("curiosity", 50)
|
||||||
|
decay_rate = 0.5 + (curiosity / 200) # Curious = bored faster
|
||||||
|
self.boredom = max(0, self.boredom - decay_rate)
|
||||||
|
|
||||||
|
def on_health_change(self, health: int, food: int):
|
||||||
|
"""Immediate update when health changes."""
|
||||||
|
if health < 6:
|
||||||
|
self.safety = min(self.safety, 10)
|
||||||
|
self.hunger = min(100, max(0, food * 5))
|
||||||
|
|
||||||
|
def on_death(self):
|
||||||
|
"""Reset after death."""
|
||||||
|
self.safety = 50
|
||||||
|
self.hunger = 80
|
||||||
|
self.social = 60
|
||||||
|
self.shelter = 30
|
||||||
|
self.boredom = 60
|
||||||
|
|
||||||
|
def on_player_nearby(self):
|
||||||
|
"""Boost social when a player appears."""
|
||||||
|
self.social = min(100, self.social + 15)
|
||||||
|
|
||||||
|
def on_scan(self, hostiles_nearby: int, players_nearby: int):
|
||||||
|
"""Update from scan results."""
|
||||||
|
if hostiles_nearby == 0:
|
||||||
|
self.safety = min(100, self.safety + 5)
|
||||||
|
if players_nearby > 0:
|
||||||
|
self.social = min(100, self.social + 3)
|
||||||
|
|
||||||
|
def get_critical_needs(self) -> list[str]:
|
||||||
|
"""Get list of critically low needs for context."""
|
||||||
|
critical = []
|
||||||
|
if self.safety < 25:
|
||||||
|
critical.append("unsafe")
|
||||||
|
if self.hunger < 25:
|
||||||
|
critical.append("hungry")
|
||||||
|
if self.social < 20:
|
||||||
|
critical.append("lonely")
|
||||||
|
if self.shelter < 20:
|
||||||
|
critical.append("exposed")
|
||||||
|
if self.boredom < 15:
|
||||||
|
critical.append("bored")
|
||||||
|
return critical
|
||||||
|
|
||||||
|
def most_urgent_need(self) -> tuple[str, int]:
|
||||||
|
"""Return the name and value of the most urgent (lowest) need."""
|
||||||
|
needs = {
|
||||||
|
"safety": self.safety,
|
||||||
|
"hunger": self.hunger,
|
||||||
|
"social": self.social,
|
||||||
|
"shelter": self.shelter,
|
||||||
|
"boredom": self.boredom,
|
||||||
|
}
|
||||||
|
lowest = min(needs, key=needs.get)
|
||||||
|
return lowest, needs[lowest]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# GOAL MANAGER — long-term goals broken into steps
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class GoalManager:
|
||||||
|
"""
|
||||||
|
Manages Doug's long-term goals. Each goal is a dict:
|
||||||
|
{
|
||||||
|
"name": "gather_wood",
|
||||||
|
"description": "Gather wood for building",
|
||||||
|
"priority": 5, # 1-10
|
||||||
|
"steps": [...], # List of step dicts
|
||||||
|
"current_step": 0,
|
||||||
|
"created_at": time,
|
||||||
|
"status": "active" | "complete" | "failed"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Goal templates — what Doug knows how to do
|
||||||
|
GOAL_TEMPLATES = {
|
||||||
|
"gather_wood": {
|
||||||
|
"description": "Gather some wood",
|
||||||
|
"priority": 5,
|
||||||
|
"steps": [
|
||||||
|
{"action": "find_blocks", "params": {"blockName": "oak_log", "radius": 32, "count": 1},
|
||||||
|
"description": "Finding trees"},
|
||||||
|
{"action": "dig_block", "params": {}, # position filled from find result
|
||||||
|
"description": "Chopping wood"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"explore_area": {
|
||||||
|
"description": "Explore the surroundings",
|
||||||
|
"priority": 3,
|
||||||
|
"steps": [
|
||||||
|
{"action": "move_to", "params": {"range": 2},
|
||||||
|
"description": "Exploring a new area"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"find_food": {
|
||||||
|
"description": "Find something to eat",
|
||||||
|
"priority": 7,
|
||||||
|
"steps": [
|
||||||
|
{"action": "find_blocks",
|
||||||
|
"params": {"blockName": "wheat", "radius": 32, "count": 1},
|
||||||
|
"description": "Looking for food sources"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"check_container": {
|
||||||
|
"description": "Check a nearby container",
|
||||||
|
"priority": 4,
|
||||||
|
"steps": [
|
||||||
|
{"action": "move_to", "params": {"range": 2},
|
||||||
|
"description": "Going to the container"},
|
||||||
|
{"action": "open_chest", "params": {},
|
||||||
|
"description": "Opening the container"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"visit_interesting": {
|
||||||
|
"description": "Check out something interesting",
|
||||||
|
"priority": 3,
|
||||||
|
"steps": [
|
||||||
|
{"action": "move_to", "params": {"range": 2},
|
||||||
|
"description": "Going to check it out"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"go_home": {
|
||||||
|
"description": "Head back home",
|
||||||
|
"priority": 6,
|
||||||
|
"steps": [
|
||||||
|
{"action": "move_to", "params": {"range": 3},
|
||||||
|
"description": "Walking home"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, traits: dict, age: int):
|
||||||
|
self._traits = traits
|
||||||
|
self._age = age
|
||||||
|
self._goals: list[dict] = []
|
||||||
|
self._completed_goals: list[str] = [] # names of recently completed goals
|
||||||
|
self._max_goals = 5 # Don't pile up too many goals
|
||||||
|
|
||||||
|
def has_any_goals(self) -> bool:
|
||||||
|
return any(g["status"] == "active" for g in self._goals)
|
||||||
|
|
||||||
|
def has_goal(self, name: str) -> bool:
|
||||||
|
return any(g["name"] == name and g["status"] == "active" for g in self._goals)
|
||||||
|
|
||||||
|
def add_goal(self, name: str, priority: int = 5, target_pos: dict = None,
|
||||||
|
extra_params: dict = None):
|
||||||
|
"""Add a goal from templates."""
|
||||||
|
if self.has_goal(name):
|
||||||
|
return
|
||||||
|
if len([g for g in self._goals if g["status"] == "active"]) >= self._max_goals:
|
||||||
|
# Remove lowest priority active goal
|
||||||
|
active = [g for g in self._goals if g["status"] == "active"]
|
||||||
|
active.sort(key=lambda g: g["priority"])
|
||||||
|
if active and active[0]["priority"] < priority:
|
||||||
|
active[0]["status"] = "dropped"
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
template = self.GOAL_TEMPLATES.get(name)
|
||||||
|
if not template:
|
||||||
|
log.debug(f"Unknown goal template: {name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
import copy
|
||||||
|
goal = {
|
||||||
|
"name": name,
|
||||||
|
"description": template["description"],
|
||||||
|
"priority": priority,
|
||||||
|
"steps": copy.deepcopy(template["steps"]),
|
||||||
|
"current_step": 0,
|
||||||
|
"created_at": time.time(),
|
||||||
|
"status": "active",
|
||||||
|
"target_pos": target_pos,
|
||||||
|
"extra_params": extra_params or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fill in target position for movement steps
|
||||||
|
if target_pos:
|
||||||
|
for step in goal["steps"]:
|
||||||
|
if step["action"] == "move_to":
|
||||||
|
step["params"].update(target_pos)
|
||||||
|
elif step["action"] == "open_chest":
|
||||||
|
step["params"].update(target_pos)
|
||||||
|
|
||||||
|
self._goals.append(goal)
|
||||||
|
log.info(f"New goal: {goal['description']} (priority {priority})")
|
||||||
|
|
||||||
|
def get_active_goal(self) -> dict | None:
|
||||||
|
"""Get the highest priority active goal."""
|
||||||
|
active = [g for g in self._goals if g["status"] == "active"]
|
||||||
|
if not active:
|
||||||
|
return None
|
||||||
|
active.sort(key=lambda g: g["priority"], reverse=True)
|
||||||
|
return active[0]
|
||||||
|
|
||||||
|
def get_next_step(self, goal: dict, behaviors, memory) -> Task | None:
|
||||||
|
"""Get the next task from a goal's step list."""
|
||||||
|
if goal["current_step"] >= len(goal["steps"]):
|
||||||
|
return None # All steps done
|
||||||
|
|
||||||
|
step = goal["steps"][goal["current_step"]]
|
||||||
|
goal["current_step"] += 1
|
||||||
|
|
||||||
|
# Build the task from the step
|
||||||
|
params = dict(step["params"])
|
||||||
|
params.update(goal.get("extra_params", {}))
|
||||||
|
|
||||||
|
# For find_blocks, we need a callback to process the result
|
||||||
|
if step["action"] == "find_blocks":
|
||||||
|
return Task(
|
||||||
|
name=f"goal_{goal['name']}_find",
|
||||||
|
priority=Priority.NORMAL,
|
||||||
|
action=step["action"],
|
||||||
|
params=params,
|
||||||
|
description=step["description"],
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
name=f"goal_{goal['name']}_step{goal['current_step']}",
|
||||||
|
priority=Priority.NORMAL,
|
||||||
|
action=step["action"],
|
||||||
|
params=params,
|
||||||
|
description=step["description"],
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete_goal(self, name: str):
|
||||||
|
"""Mark a goal as complete."""
|
||||||
|
for g in self._goals:
|
||||||
|
if g["name"] == name and g["status"] == "active":
|
||||||
|
g["status"] = "complete"
|
||||||
|
self._completed_goals.append(name)
|
||||||
|
if len(self._completed_goals) > 20:
|
||||||
|
self._completed_goals.pop(0)
|
||||||
|
log.info(f"Goal complete: {g['description']}")
|
||||||
|
break
|
||||||
|
# Clean up old goals
|
||||||
|
self._goals = [g for g in self._goals
|
||||||
|
if g["status"] == "active"
|
||||||
|
or (time.time() - g["created_at"]) < 300]
|
||||||
|
|
||||||
|
def on_death(self):
|
||||||
|
"""Clear all goals on death."""
|
||||||
|
for g in self._goals:
|
||||||
|
if g["status"] == "active":
|
||||||
|
g["status"] = "failed"
|
||||||
|
self._goals.clear()
|
||||||
|
|
||||||
|
def seed_initial_goals(self, memory, behaviors):
|
||||||
|
"""Create starting goals based on persona traits."""
|
||||||
|
curiosity = self._traits.get("curiosity", 50)
|
||||||
|
|
||||||
|
# Everyone starts by exploring their surroundings
|
||||||
|
self.add_goal("explore_area", priority=3,
|
||||||
|
target_pos=self._random_nearby_pos(behaviors.position, 15))
|
||||||
|
|
||||||
|
# Curious Dougs want to explore more
|
||||||
|
if curiosity > 60:
|
||||||
|
self.add_goal("explore_area", priority=4,
|
||||||
|
target_pos=self._random_nearby_pos(behaviors.position, 25))
|
||||||
|
|
||||||
|
# If containers are visible, check them
|
||||||
|
if behaviors.nearby_containers:
|
||||||
|
container = behaviors.nearby_containers[0]
|
||||||
|
self.add_goal("check_container", priority=4,
|
||||||
|
target_pos=container["position"])
|
||||||
|
|
||||||
|
def generate_goal_from_environment(self, memory, behaviors):
|
||||||
|
"""Generate a new goal based on what we know about the world."""
|
||||||
|
# Check memory for interesting things
|
||||||
|
containers = memory.get_known("container")
|
||||||
|
if containers and not self.has_goal("check_container"):
|
||||||
|
# Visit nearest unvisited container
|
||||||
|
nearest = min(containers, key=lambda c: _dist(behaviors.position, c))
|
||||||
|
self.add_goal("check_container", priority=4, target_pos=nearest)
|
||||||
|
return
|
||||||
|
|
||||||
|
crafting_tables = memory.get_known("crafting_table")
|
||||||
|
if crafting_tables and not self.has_goal("visit_interesting"):
|
||||||
|
nearest = min(crafting_tables, key=lambda c: _dist(behaviors.position, c))
|
||||||
|
self.add_goal("visit_interesting", priority=3, target_pos=nearest)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default: gather wood (always useful)
|
||||||
|
if not self.has_goal("gather_wood") and random.random() < 0.3:
|
||||||
|
self.add_goal("gather_wood", priority=4)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Explore somewhere new
|
||||||
|
if not self.has_goal("explore_area"):
|
||||||
|
self.add_goal("explore_area", priority=2,
|
||||||
|
target_pos=self._random_nearby_pos(behaviors.position, 20))
|
||||||
|
|
||||||
|
def _random_nearby_pos(self, pos: dict, radius: float) -> dict:
|
||||||
|
angle = random.uniform(0, 2 * math.pi)
|
||||||
|
dist = random.uniform(radius * 0.5, radius)
|
||||||
|
return {
|
||||||
|
"x": pos["x"] + math.cos(angle) * dist,
|
||||||
|
"y": pos["y"],
|
||||||
|
"z": pos["z"] + math.sin(angle) * dist,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# SPATIAL MEMORY — remembers where things are
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class SpatialMemory:
|
||||||
|
"""
|
||||||
|
Remembers locations Doug has seen and been to.
|
||||||
|
Stores positions of interesting things: containers, crafting tables,
|
||||||
|
players, hostile spawn areas, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.home: dict | None = None # Home base position
|
||||||
|
self._known_locations: list[dict] = [] # {type, x, y, z, seen_at}
|
||||||
|
self._explored_chunks: set[tuple[int, int]] = set() # (chunk_x, chunk_z)
|
||||||
|
self._visited: list[dict] = [] # positions we've been to
|
||||||
|
self._max_locations = 200
|
||||||
|
self._max_visited = 50
|
||||||
|
|
||||||
|
def set_home(self, pos: dict):
|
||||||
|
self.home = {"x": pos["x"], "y": pos["y"], "z": pos["z"]}
|
||||||
|
|
||||||
|
def update_from_scan(self, position: dict, blocks: dict,
|
||||||
|
containers: list, players: list, hostiles: list):
|
||||||
|
"""Process scan results into memory."""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Record that we've been here
|
||||||
|
chunk = (int(position["x"]) // 16, int(position["z"]) // 16)
|
||||||
|
self._explored_chunks.add(chunk)
|
||||||
|
self._visited.append({"x": position["x"], "y": position["y"],
|
||||||
|
"z": position["z"], "time": now})
|
||||||
|
if len(self._visited) > self._max_visited:
|
||||||
|
self._visited.pop(0)
|
||||||
|
|
||||||
|
# Remember containers
|
||||||
|
for c in containers:
|
||||||
|
self._remember(c.get("type", "container"), c["position"], now)
|
||||||
|
|
||||||
|
# Remember interesting blocks
|
||||||
|
interesting_blocks = {
|
||||||
|
"crafting_table", "furnace", "chest", "ender_chest",
|
||||||
|
"enchanting_table", "anvil", "brewing_stand", "diamond_ore",
|
||||||
|
"iron_ore", "gold_ore", "coal_ore",
|
||||||
|
}
|
||||||
|
for block_type, positions in blocks.items():
|
||||||
|
if block_type in interesting_blocks:
|
||||||
|
for pos in positions[:3]: # Don't remember too many of one type
|
||||||
|
self._remember(block_type, pos, now)
|
||||||
|
|
||||||
|
# Remember where hostiles spawned (danger zones)
|
||||||
|
for h in hostiles:
|
||||||
|
if "position" in h:
|
||||||
|
self._remember(f"hostile_{h.get('type', 'mob')}", h["position"], now)
|
||||||
|
|
||||||
|
def _remember(self, loc_type: str, pos: dict, timestamp: float):
|
||||||
|
"""Add or update a location in memory."""
|
||||||
|
# Check if we already know about this spot (within 3 blocks)
|
||||||
|
for loc in self._known_locations:
|
||||||
|
if loc["type"] == loc_type:
|
||||||
|
dx = loc["x"] - pos.get("x", 0)
|
||||||
|
dz = loc["z"] - pos.get("z", 0)
|
||||||
|
if dx * dx + dz * dz < 9: # Within 3 blocks
|
||||||
|
loc["seen_at"] = timestamp
|
||||||
|
return
|
||||||
|
|
||||||
|
# New location
|
||||||
|
self._known_locations.append({
|
||||||
|
"type": loc_type,
|
||||||
|
"x": pos.get("x", 0),
|
||||||
|
"y": pos.get("y", 0),
|
||||||
|
"z": pos.get("z", 0),
|
||||||
|
"seen_at": timestamp,
|
||||||
|
"visited": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Trim old locations if too many
|
||||||
|
if len(self._known_locations) > self._max_locations:
|
||||||
|
self._known_locations.sort(key=lambda l: l["seen_at"])
|
||||||
|
self._known_locations = self._known_locations[-self._max_locations:]
|
||||||
|
|
||||||
|
def get_known(self, loc_type: str) -> list[dict]:
|
||||||
|
"""Get all known locations of a given type."""
|
||||||
|
return [l for l in self._known_locations if l["type"] == loc_type]
|
||||||
|
|
||||||
|
def get_nearest_unexplored(self, pos: dict, max_dist: float = 50) -> dict | None:
|
||||||
|
"""Find nearest interesting unvisited location."""
|
||||||
|
unvisited = [l for l in self._known_locations
|
||||||
|
if not l.get("visited") and not l["type"].startswith("hostile_")]
|
||||||
|
if not unvisited:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort by distance
|
||||||
|
def dist(loc):
|
||||||
|
dx = pos["x"] - loc["x"]
|
||||||
|
dz = pos["z"] - loc["z"]
|
||||||
|
return math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
|
unvisited.sort(key=dist)
|
||||||
|
nearest = unvisited[0]
|
||||||
|
d = dist(nearest)
|
||||||
|
|
||||||
|
if d > max_dist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mark as visited so we don't go back immediately
|
||||||
|
nearest["visited"] = True
|
||||||
|
return nearest
|
||||||
|
|
||||||
|
def suggest_explore_angle(self, pos: dict) -> float:
|
||||||
|
"""Suggest a direction to explore that we haven't visited much."""
|
||||||
|
# Check which directions we've explored
|
||||||
|
chunk_x = int(pos["x"]) // 16
|
||||||
|
chunk_z = int(pos["z"]) // 16
|
||||||
|
|
||||||
|
# Score each of 8 directions
|
||||||
|
best_angle = random.uniform(0, 2 * math.pi)
|
||||||
|
best_score = -1
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
angle = (2 * math.pi * i) / 8
|
||||||
|
# Check chunks in this direction
|
||||||
|
check_x = chunk_x + int(math.cos(angle) * 2)
|
||||||
|
check_z = chunk_z + int(math.sin(angle) * 2)
|
||||||
|
|
||||||
|
# Score: prefer unexplored chunks
|
||||||
|
score = 0
|
||||||
|
for dx in range(-1, 2):
|
||||||
|
for dz in range(-1, 2):
|
||||||
|
if (check_x + dx, check_z + dz) not in self._explored_chunks:
|
||||||
|
score += 1
|
||||||
|
|
||||||
|
# Add some randomness
|
||||||
|
score += random.uniform(0, 2)
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_angle = angle
|
||||||
|
|
||||||
|
return best_angle
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# DAILY ROUTINE — time-of-day phases
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DailyRoutine:
|
||||||
|
"""
|
||||||
|
Maps Minecraft time-of-day to activity phases.
|
||||||
|
MC day ticks: 0=sunrise, 6000=noon, 12000=sunset, 18000=midnight
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, traits: dict, age: int):
|
||||||
|
self._traits = traits
|
||||||
|
self._age = age
|
||||||
|
|
||||||
|
def get_phase(self, day_time: int) -> str:
|
||||||
|
"""Get the current routine phase."""
|
||||||
|
# Normalize to 0-24000
|
||||||
|
t = day_time % 24000
|
||||||
|
|
||||||
|
if t < 1000:
|
||||||
|
return "morning" # 0-1000: sunrise, wake up
|
||||||
|
elif t < 11000:
|
||||||
|
return "day" # 1000-11000: main working hours
|
||||||
|
elif t < 12500:
|
||||||
|
return "evening" # 11000-12500: sunset, wind down
|
||||||
|
else:
|
||||||
|
return "night" # 12500-24000: nighttime
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BEHAVIOR ENGINE — world state container (kept for compatibility)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class BehaviorEngine:
|
class BehaviorEngine:
|
||||||
"""Generates tasks based on Doug's state, surroundings, and personality."""
|
"""
|
||||||
|
Holds Doug's world state. Updated by brain from scan results.
|
||||||
|
Kept for backward compatibility — main_window accesses these fields.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, traits: dict, age: int, doug_name: str):
|
def __init__(self, traits: dict, age: int, doug_name: str):
|
||||||
self._traits = traits # Persona trait values
|
self._traits = traits
|
||||||
self._age = age
|
self._age = age
|
||||||
self._name = doug_name
|
self._name = doug_name
|
||||||
|
|
||||||
|
|
@ -37,349 +583,17 @@ class BehaviorEngine:
|
||||||
self.inventory: list[dict] = []
|
self.inventory: list[dict] = []
|
||||||
self.spawn_pos = {"x": 0, "y": 0, "z": 0}
|
self.spawn_pos = {"x": 0, "y": 0, "z": 0}
|
||||||
|
|
||||||
# Behavior state
|
|
||||||
self._last_scan_time = 0.0
|
|
||||||
self._last_chat_time = 0.0
|
|
||||||
self._last_wander_time = 0.0
|
|
||||||
self._last_combat_time = 0.0
|
|
||||||
self._explored_positions: list[dict] = [] # Places we've been
|
|
||||||
self._known_containers: list[dict] = [] # Containers we've found
|
|
||||||
self._relationships: dict[str, float] = {} # Player name → fondness (-1 to 1)
|
|
||||||
self._deaths_seen: list[dict] = []
|
|
||||||
|
|
||||||
# --- Trait helpers ---
|
|
||||||
|
|
||||||
def _trait(self, name: str, default: int = 50) -> int:
|
|
||||||
"""Get a trait value (0-100 slider) or bool quirk."""
|
|
||||||
return self._traits.get(name, default)
|
|
||||||
|
|
||||||
def _has_quirk(self, name: str) -> bool:
|
|
||||||
"""Check if a boolean quirk is enabled."""
|
|
||||||
return bool(self._traits.get(name, False))
|
|
||||||
|
|
||||||
def _trait_chance(self, trait_name: str, base: float = 0.5) -> bool:
|
|
||||||
"""Random check weighted by a trait. Higher trait = more likely."""
|
|
||||||
val = self._trait(trait_name, 50) / 100.0
|
|
||||||
return random.random() < (base * val)
|
|
||||||
|
|
||||||
# --- Core behavior generators ---
|
|
||||||
|
|
||||||
def get_survival_task(self) -> Optional[Task]:
|
|
||||||
"""Check for survival needs — health, food, immediate danger."""
|
|
||||||
|
|
||||||
# Critical health — flee from everything
|
|
||||||
if self.health <= 4:
|
|
||||||
hostile = self._nearest_hostile(12)
|
|
||||||
if hostile:
|
|
||||||
return self._flee_task(hostile, "Critical health!")
|
|
||||||
|
|
||||||
# Flee from close hostiles based on bravery
|
|
||||||
bravery = self._trait("bravery", 50)
|
|
||||||
flee_distance = max(4, 12 - bravery // 10) # Brave = smaller flee radius
|
|
||||||
flee_health_threshold = max(6, 18 - bravery // 8) # Brave = lower threshold
|
|
||||||
|
|
||||||
close_hostile = self._nearest_hostile(flee_distance)
|
|
||||||
if close_hostile and self.health < flee_health_threshold:
|
|
||||||
return self._flee_task(close_hostile, f"Fleeing from {close_hostile.get('type', 'mob')}")
|
|
||||||
|
|
||||||
# Anxiety quirk: flee from ANY hostile within 10 blocks regardless of health
|
|
||||||
if self._has_quirk("anxiety") and self._nearest_hostile(10):
|
|
||||||
hostile = self._nearest_hostile(10)
|
|
||||||
return self._flee_task(hostile, "Too scary!")
|
|
||||||
|
|
||||||
# Night fear (anxiety): seek shelter
|
|
||||||
if self._has_quirk("anxiety") and self.is_night and not self._is_near_shelter():
|
|
||||||
return Task(
|
|
||||||
name="seek_shelter",
|
|
||||||
priority=Priority.URGENT,
|
|
||||||
action="move_to",
|
|
||||||
params={**self.spawn_pos, "range": 3},
|
|
||||||
description="Running back to safety",
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Eat if hungry and we have food
|
|
||||||
if self.food <= 8:
|
|
||||||
food_item = self._find_food_in_inventory()
|
|
||||||
if food_item:
|
|
||||||
return Task(
|
|
||||||
name="eat",
|
|
||||||
priority=Priority.URGENT,
|
|
||||||
action="equip_item",
|
|
||||||
params={"name": food_item, "destination": "hand"},
|
|
||||||
description=f"Eating {food_item}",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_social_task(self) -> Optional[Task]:
|
|
||||||
"""Social behaviors — interact with nearby players."""
|
|
||||||
if not self.nearby_players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sociability = self._trait("sociability", 50)
|
|
||||||
|
|
||||||
# Follow nearby player if sociable and they're far-ish
|
|
||||||
for player in self.nearby_players:
|
|
||||||
dist = player.get("distance", 99)
|
|
||||||
|
|
||||||
# Very social Doug follows players around
|
|
||||||
if sociability > 70 and dist > 6 and dist < 30:
|
|
||||||
if self._trait_chance("sociability", 0.3):
|
|
||||||
return Task(
|
|
||||||
name=f"follow_{player['name']}",
|
|
||||||
priority=Priority.LOW,
|
|
||||||
action="follow_player",
|
|
||||||
params={"name": player["name"], "range": 4},
|
|
||||||
description=f"Following {player['name']}",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Walk toward player if they're close enough to interact
|
|
||||||
if dist > 3 and dist < 15 and self._trait_chance("sociability", 0.15):
|
|
||||||
return Task(
|
|
||||||
name=f"approach_{player['name']}",
|
|
||||||
priority=Priority.LOW,
|
|
||||||
action="move_to",
|
|
||||||
params={**player["position"], "range": 3},
|
|
||||||
description=f"Walking toward {player['name']}",
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_exploration_task(self) -> Optional[Task]:
|
|
||||||
"""Exploration and curiosity behaviors."""
|
|
||||||
curiosity = self._trait("curiosity", 50)
|
|
||||||
|
|
||||||
# Check signs nearby
|
|
||||||
if self.nearby_signs and self._trait_chance("curiosity", 0.5):
|
|
||||||
sign = self.nearby_signs[0]
|
|
||||||
sign_pos = sign["position"]
|
|
||||||
dist = self._distance_to_pos(sign_pos)
|
|
||||||
if dist > 2:
|
|
||||||
return Task(
|
|
||||||
name="read_sign",
|
|
||||||
priority=Priority.NORMAL,
|
|
||||||
action="move_to",
|
|
||||||
params={**sign_pos, "range": 2},
|
|
||||||
description=f"Going to read a sign",
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check containers nearby (OCD quirk = organize, curiosity = peek)
|
|
||||||
if self.nearby_containers:
|
|
||||||
for container in self.nearby_containers:
|
|
||||||
dist = self._distance_to_pos(container["position"])
|
|
||||||
if dist < 5:
|
|
||||||
if self._has_quirk("ocd") or self._trait_chance("curiosity", 0.2):
|
|
||||||
return Task(
|
|
||||||
name="check_container",
|
|
||||||
priority=Priority.NORMAL,
|
|
||||||
action="open_chest",
|
|
||||||
params=container["position"],
|
|
||||||
description=f"Checking a {container['type']}",
|
|
||||||
timeout=15,
|
|
||||||
callback="on_container_opened",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Interesting blocks nearby
|
|
||||||
if self.nearby_blocks and curiosity > 40:
|
|
||||||
for block_type, positions in self.nearby_blocks.items():
|
|
||||||
if block_type == "crafting_table" and self._trait_chance("curiosity", 0.1):
|
|
||||||
pos = positions[0]
|
|
||||||
return Task(
|
|
||||||
name="visit_crafting_table",
|
|
||||||
priority=Priority.LOW,
|
|
||||||
action="move_to",
|
|
||||||
params={**pos, "range": 2},
|
|
||||||
description="Checking out a crafting table",
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wander/explore — higher curiosity = farther, more frequent
|
|
||||||
time_since_wander = time.time() - self._last_wander_time
|
|
||||||
wander_interval = max(4, 15 - curiosity // 8) # Curious = shorter interval
|
|
||||||
|
|
||||||
if time_since_wander > wander_interval:
|
|
||||||
self._last_wander_time = time.time()
|
|
||||||
return self._wander_task(curiosity)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_combat_task(self) -> Optional[Task]:
|
|
||||||
"""Combat behaviors — attack hostiles based on bravery."""
|
|
||||||
bravery = self._trait("bravery", 50)
|
|
||||||
|
|
||||||
# Only attack if brave enough
|
|
||||||
if bravery < 30:
|
|
||||||
return None # Too scared to fight
|
|
||||||
|
|
||||||
# Cooldown — don't spam combat tasks
|
|
||||||
if time.time() - self._last_combat_time < 12:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Find attackable hostile within melee range
|
|
||||||
for hostile in self.nearby_hostiles:
|
|
||||||
dist = hostile.get("distance", 99)
|
|
||||||
if dist < 5 and self.health > 8:
|
|
||||||
# Brave Dougs attack, others might not
|
|
||||||
if bravery > 60 or (bravery > 40 and self.health > 14):
|
|
||||||
self._last_combat_time = time.time()
|
|
||||||
return Task(
|
|
||||||
name="combat",
|
|
||||||
priority=Priority.HIGH,
|
|
||||||
action="attack_nearest_hostile",
|
|
||||||
params={"range": 6},
|
|
||||||
description=f"Fighting a {hostile['type']}!",
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_organization_task(self) -> Optional[Task]:
|
|
||||||
"""OCD/organization behaviors."""
|
|
||||||
if not self._has_quirk("ocd"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# If we have a messy inventory, organize it
|
|
||||||
if len(self.inventory) > 10 and random.random() < 0.05:
|
|
||||||
return Task(
|
|
||||||
name="organize_inventory",
|
|
||||||
priority=Priority.LOW,
|
|
||||||
action="status", # Placeholder — will be multi-step
|
|
||||||
description="Organizing my stuff",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_idle_task(self) -> Optional[Task]:
|
|
||||||
"""Idle behaviors — what Doug does when bored."""
|
|
||||||
# Look around randomly
|
|
||||||
if random.random() < 0.4:
|
|
||||||
return Task(
|
|
||||||
name="look_around",
|
|
||||||
priority=Priority.IDLE,
|
|
||||||
action="look_at",
|
|
||||||
params={
|
|
||||||
"x": self.position["x"] + random.uniform(-20, 20),
|
|
||||||
"y": self.position["y"] + random.uniform(-3, 5),
|
|
||||||
"z": self.position["z"] + random.uniform(-20, 20),
|
|
||||||
},
|
|
||||||
description="",
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Chatty Cathy: say something unprompted
|
|
||||||
if self._has_quirk("chatty_cathy") and self.nearby_players:
|
|
||||||
time_since_chat = time.time() - self._last_chat_time
|
|
||||||
if time_since_chat > 30 and random.random() < 0.15:
|
|
||||||
self._last_chat_time = time.time()
|
|
||||||
return Task(
|
|
||||||
name="idle_chat",
|
|
||||||
priority=Priority.LOW,
|
|
||||||
action="status", # Brain will handle via AI
|
|
||||||
description="chatting",
|
|
||||||
timeout=10,
|
|
||||||
callback="on_idle_chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# --- Task factories ---
|
|
||||||
|
|
||||||
def _flee_task(self, hostile: dict, reason: str) -> Task:
|
|
||||||
"""Create a flee task away from a hostile."""
|
|
||||||
hpos = hostile.get("position", self.position)
|
|
||||||
dx = self.position["x"] - hpos.get("x", 0)
|
|
||||||
dz = self.position["z"] - hpos.get("z", 0)
|
|
||||||
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
|
||||||
|
|
||||||
flee_dist = 12
|
|
||||||
flee_x = self.position["x"] + (dx / dist) * flee_dist
|
|
||||||
flee_z = self.position["z"] + (dz / dist) * flee_dist
|
|
||||||
|
|
||||||
return Task(
|
|
||||||
name="flee",
|
|
||||||
priority=Priority.URGENT,
|
|
||||||
action="move_to",
|
|
||||||
params={"x": flee_x, "y": self.position["y"], "z": flee_z, "range": 3},
|
|
||||||
description=reason,
|
|
||||||
timeout=15,
|
|
||||||
interruptible=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _wander_task(self, curiosity: int) -> Task:
|
|
||||||
"""Create a wander task with distance based on curiosity."""
|
|
||||||
angle = random.uniform(0, 2 * math.pi)
|
|
||||||
dist = random.uniform(5, 8 + curiosity // 10) # Curious = farther
|
|
||||||
|
|
||||||
target_x = self.position["x"] + math.cos(angle) * dist
|
|
||||||
target_z = self.position["z"] + math.sin(angle) * dist
|
|
||||||
|
|
||||||
# Don't wander too far from spawn (radius based on curiosity)
|
|
||||||
max_radius = 30 + curiosity // 2 # Curious = wider range
|
|
||||||
dx = target_x - self.spawn_pos["x"]
|
|
||||||
dz = target_z - self.spawn_pos["z"]
|
|
||||||
if math.sqrt(dx * dx + dz * dz) > max_radius:
|
|
||||||
# Head back toward spawn
|
|
||||||
angle = math.atan2(
|
|
||||||
self.spawn_pos["z"] - self.position["z"],
|
|
||||||
self.spawn_pos["x"] - self.position["x"],
|
|
||||||
)
|
|
||||||
target_x = self.position["x"] + math.cos(angle) * 8
|
|
||||||
target_z = self.position["z"] + math.sin(angle) * 8
|
|
||||||
|
|
||||||
return Task(
|
|
||||||
name="wander",
|
|
||||||
priority=Priority.IDLE,
|
|
||||||
action="move_to",
|
|
||||||
params={"x": target_x, "y": self.position["y"], "z": target_z, "range": 2},
|
|
||||||
description="",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Helpers ---
|
|
||||||
|
|
||||||
def _nearest_hostile(self, max_dist: float) -> Optional[dict]:
|
|
||||||
"""Get nearest hostile within max_dist."""
|
|
||||||
closest = None
|
|
||||||
closest_dist = max_dist
|
|
||||||
for h in self.nearby_hostiles:
|
|
||||||
d = h.get("distance", 99)
|
|
||||||
if d < closest_dist:
|
|
||||||
closest = h
|
|
||||||
closest_dist = d
|
|
||||||
return closest
|
|
||||||
|
|
||||||
def _distance_to_pos(self, pos: dict) -> float:
|
|
||||||
dx = self.position["x"] - pos.get("x", 0)
|
|
||||||
dy = self.position["y"] - pos.get("y", 0)
|
|
||||||
dz = self.position["z"] - pos.get("z", 0)
|
|
||||||
return math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
||||||
|
|
||||||
def _find_food_in_inventory(self) -> Optional[str]:
|
|
||||||
"""Find a food item in inventory."""
|
|
||||||
food_items = {
|
|
||||||
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
|
|
||||||
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
|
|
||||||
"golden_apple", "melon_slice", "sweet_berries", "baked_potato",
|
|
||||||
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie", "pumpkin_pie",
|
|
||||||
"cake", "dried_kelp", "carrot", "potato",
|
|
||||||
}
|
|
||||||
for item in self.inventory:
|
|
||||||
if item.get("name", "").replace("minecraft:", "") in food_items:
|
|
||||||
return item["name"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _is_near_shelter(self) -> bool:
|
|
||||||
"""Check if Doug is near a sheltered area (has blocks above)."""
|
|
||||||
# Simplified: near spawn = near shelter
|
|
||||||
d = self._distance_to_pos(self.spawn_pos)
|
|
||||||
return d < 15
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_night(self) -> bool:
|
def is_night(self) -> bool:
|
||||||
return self.day_time > 12000
|
return self.day_time > 12000
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Helpers
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _dist(pos_a: dict, pos_b: dict) -> float:
|
||||||
|
"""Distance between two position dicts."""
|
||||||
|
dx = pos_a.get("x", 0) - pos_b.get("x", 0)
|
||||||
|
dz = pos_a.get("z", 0) - pos_b.get("z", 0)
|
||||||
|
return math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,47 @@
|
||||||
"""
|
"""
|
||||||
Doug's Brain — the autonomous decision loop.
|
Doug's Brain — goal-based autonomous decision engine.
|
||||||
Uses behavior engine + task queue for trait-driven decisions.
|
|
||||||
Ticks every 2 seconds: scan → generate tasks → execute top task.
|
Instead of randomly generating tasks each tick, Doug now has:
|
||||||
|
- A NEEDS system (safety, hunger, social, shelter, boredom) that drives urgency
|
||||||
|
- A GOALS system (long-term objectives broken into steps)
|
||||||
|
- A MEMORY system (remembers locations of things he's seen)
|
||||||
|
- A DAILY ROUTINE that adapts to time of day and persona traits
|
||||||
|
|
||||||
|
The brain ticks every 2 seconds:
|
||||||
|
1. Update needs (decay over time)
|
||||||
|
2. Process scan results into memory
|
||||||
|
3. Check if current task is still running — if so, WAIT
|
||||||
|
4. Pick the most urgent need or goal and execute the next step
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from enum import IntEnum
|
||||||
from PySide6.QtCore import QObject, QTimer, Signal
|
from PySide6.QtCore import QObject, QTimer, Signal
|
||||||
|
|
||||||
from dougbot.bridge.ws_client import BridgeWSClient
|
from dougbot.bridge.ws_client import BridgeWSClient
|
||||||
from dougbot.bridge.protocol import ResponseMessage
|
from dougbot.bridge.protocol import ResponseMessage
|
||||||
from dougbot.core.task_queue import TaskQueue, Task, Priority
|
from dougbot.core.task_queue import TaskQueue, Task, Priority
|
||||||
from dougbot.core.behaviors import BehaviorEngine
|
from dougbot.core.behaviors import (
|
||||||
|
NeedsSystem, GoalManager, SpatialMemory, DailyRoutine, BehaviorEngine,
|
||||||
|
)
|
||||||
from dougbot.utils.logging import get_logger
|
from dougbot.utils.logging import get_logger
|
||||||
|
|
||||||
log = get_logger("core.brain")
|
log = get_logger("core.brain")
|
||||||
|
|
||||||
|
|
||||||
class DougBrain(QObject):
|
class BrainState(IntEnum):
|
||||||
"""Autonomous decision engine with trait-driven behavior."""
|
"""What the brain is doing right now."""
|
||||||
|
IDLE = 0
|
||||||
|
EXECUTING_TASK = 1
|
||||||
|
WAITING_FOR_SCAN = 2
|
||||||
|
|
||||||
# Signals
|
|
||||||
|
class DougBrain(QObject):
|
||||||
|
"""Goal-based autonomous decision engine with needs and memory."""
|
||||||
|
|
||||||
|
# Signals (same interface as before)
|
||||||
wants_to_chat = Signal(str) # Unprompted chat message
|
wants_to_chat = Signal(str) # Unprompted chat message
|
||||||
wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say
|
wants_ai_chat = Signal(str, str) # (context, prompt) — ask AI what to say
|
||||||
|
|
||||||
|
|
@ -36,21 +56,33 @@ class DougBrain(QObject):
|
||||||
|
|
||||||
# Core systems
|
# Core systems
|
||||||
self._tasks = TaskQueue()
|
self._tasks = TaskQueue()
|
||||||
self._behaviors = BehaviorEngine(traits or {}, age, doug_name)
|
traits = traits or {}
|
||||||
|
self._needs = NeedsSystem(traits)
|
||||||
|
self._goals = GoalManager(traits, age)
|
||||||
|
self._memory = SpatialMemory()
|
||||||
|
self._routine = DailyRoutine(traits, age)
|
||||||
|
# BehaviorEngine is kept for compatibility (main_window accesses it)
|
||||||
|
self._behaviors = BehaviorEngine(traits, age, doug_name)
|
||||||
|
|
||||||
# Scan state
|
# Brain state
|
||||||
|
self._state = BrainState.IDLE
|
||||||
|
self._action_sent_time = 0.0
|
||||||
self._pending_scan = False
|
self._pending_scan = False
|
||||||
self._last_scan_time = 0.0
|
self._last_scan_time = 0.0
|
||||||
self._scan_interval = 3.0 # Seconds between full scans
|
self._scan_interval = 4.0 # seconds between scans
|
||||||
|
|
||||||
# Action state
|
# Tick counter for staggering updates
|
||||||
self._waiting_for_action = False
|
self._tick_count = 0
|
||||||
self._action_sent_time = 0.0
|
|
||||||
|
# Chat throttle
|
||||||
|
self._last_chat_time = 0.0
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._running = True
|
self._running = True
|
||||||
self._tick_timer.start(2000)
|
self._tick_timer.start(2000)
|
||||||
log.info("Brain started — Doug is thinking")
|
# Seed some initial goals based on persona
|
||||||
|
self._goals.seed_initial_goals(self._memory, self._behaviors)
|
||||||
|
log.info("Brain started — Doug is thinking (goal-based)")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
@ -59,6 +91,8 @@ class DougBrain(QObject):
|
||||||
self._ws.send_request("stop", {})
|
self._ws.send_request("stop", {})
|
||||||
log.info("Brain stopped")
|
log.info("Brain stopped")
|
||||||
|
|
||||||
|
# ── Event handling (same interface) ──
|
||||||
|
|
||||||
def update_from_event(self, event: str, data: dict):
|
def update_from_event(self, event: str, data: dict):
|
||||||
"""Update brain state from bridge events."""
|
"""Update brain state from bridge events."""
|
||||||
if event == "spawn_complete":
|
if event == "spawn_complete":
|
||||||
|
|
@ -67,30 +101,36 @@ class DougBrain(QObject):
|
||||||
"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)
|
"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)
|
||||||
}
|
}
|
||||||
self._behaviors.spawn_pos = dict(self._behaviors.position)
|
self._behaviors.spawn_pos = dict(self._behaviors.position)
|
||||||
|
self._memory.set_home(self._behaviors.position)
|
||||||
|
|
||||||
elif event == "health_changed":
|
elif event == "health_changed":
|
||||||
self._behaviors.health = data.get("health", 20)
|
self._behaviors.health = data.get("health", 20)
|
||||||
self._behaviors.food = data.get("food", 20)
|
self._behaviors.food = data.get("food", 20)
|
||||||
|
# Update safety need immediately on damage
|
||||||
|
self._needs.on_health_change(self._behaviors.health, self._behaviors.food)
|
||||||
|
|
||||||
elif event == "time_update":
|
elif event == "time_update":
|
||||||
self._behaviors.day_time = data.get("dayTime", 0)
|
self._behaviors.day_time = data.get("dayTime", 0)
|
||||||
|
|
||||||
elif event == "movement_complete":
|
elif event == "movement_complete":
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
self._tasks.complete()
|
self._tasks.complete()
|
||||||
|
|
||||||
elif event == "movement_failed":
|
elif event == "movement_failed":
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
self._tasks.cancel()
|
self._tasks.cancel()
|
||||||
|
|
||||||
elif event == "death":
|
elif event == "death":
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
self._tasks.clear()
|
self._tasks.clear()
|
||||||
log.info("Doug died — clearing all tasks")
|
self._needs.on_death()
|
||||||
|
self._goals.on_death()
|
||||||
|
log.info("Doug died — clearing all tasks and resetting needs")
|
||||||
|
|
||||||
elif event == "player_joined":
|
elif event == "player_joined":
|
||||||
username = data.get("username", "")
|
username = data.get("username", "")
|
||||||
if username and username != self._doug_name:
|
if username and username != self._doug_name:
|
||||||
|
self._needs.on_player_nearby()
|
||||||
log.info(f"Player joined: {username}")
|
log.info(f"Player joined: {username}")
|
||||||
|
|
||||||
elif event == "player_left":
|
elif event == "player_left":
|
||||||
|
|
@ -98,41 +138,64 @@ class DougBrain(QObject):
|
||||||
if username:
|
if username:
|
||||||
log.info(f"Player left: {username}")
|
log.info(f"Player left: {username}")
|
||||||
|
|
||||||
|
# ── Main tick ──
|
||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
"""Main brain tick — scan, generate tasks, execute."""
|
"""Main brain tick — needs → scan → decide → act."""
|
||||||
from PySide6.QtNetwork import QAbstractSocket
|
from PySide6.QtNetwork import QAbstractSocket
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
|
if self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Safety: unstick action timeout
|
self._tick_count += 1
|
||||||
if self._waiting_for_action and (time.time() - self._action_sent_time > 20):
|
|
||||||
self._waiting_for_action = False
|
# Safety: unstick action timeout (20s)
|
||||||
|
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()
|
self._tasks.cancel()
|
||||||
|
else:
|
||||||
|
return # WAIT for current task to finish — don't pile on
|
||||||
|
|
||||||
# Safety: unstick pending scan
|
# Safety: unstick pending scan
|
||||||
if self._pending_scan and (time.time() - self._last_scan_time > 10):
|
if self._pending_scan and (time.time() - self._last_scan_time > 10):
|
||||||
self._pending_scan = False
|
self._pending_scan = False
|
||||||
|
|
||||||
# Step 1: Scan surroundings periodically
|
# Step 1: Decay needs every tick (every 2s)
|
||||||
|
self._needs.decay(
|
||||||
|
health=self._behaviors.health,
|
||||||
|
food=self._behaviors.food,
|
||||||
|
has_shelter=self._is_sheltered(),
|
||||||
|
is_night=self._behaviors.is_night,
|
||||||
|
has_players_nearby=bool(self._behaviors.nearby_players),
|
||||||
|
hostiles_nearby=len(self._behaviors.nearby_hostiles),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Scan surroundings periodically
|
||||||
if not self._pending_scan and (time.time() - self._last_scan_time > self._scan_interval):
|
if not self._pending_scan and (time.time() - self._last_scan_time > self._scan_interval):
|
||||||
self._pending_scan = True
|
self._pending_scan = True
|
||||||
self._last_scan_time = time.time()
|
self._last_scan_time = time.time()
|
||||||
self._ws.send_request("scan_surroundings", {"radius": 12}, self._on_scan)
|
self._ws.send_request("scan_surroundings", {"radius": 16}, self._on_scan)
|
||||||
self._ws.send_request("get_inventory", {}, self._on_inventory)
|
self._ws.send_request("get_inventory", {}, self._on_inventory)
|
||||||
return # Wait for scan results before deciding
|
return # Wait for scan before deciding
|
||||||
|
|
||||||
# Step 2: Generate tasks from behaviors (if not waiting for scan)
|
if self._pending_scan:
|
||||||
if not self._pending_scan and not self._waiting_for_action:
|
return # Still waiting for scan
|
||||||
self._generate_tasks()
|
|
||||||
|
|
||||||
# Step 3: Execute top task
|
# Step 3: Decide what to do
|
||||||
if not self._waiting_for_action:
|
task = self._decide()
|
||||||
|
if task:
|
||||||
|
self._tasks.add(task)
|
||||||
|
|
||||||
|
# Step 4: Execute top task from queue
|
||||||
self._execute_next_task()
|
self._execute_next_task()
|
||||||
|
|
||||||
|
# ── Scanning & Memory ──
|
||||||
|
|
||||||
def _on_scan(self, response: ResponseMessage):
|
def _on_scan(self, response: ResponseMessage):
|
||||||
"""Process surroundings scan."""
|
"""Process scan results and update memory."""
|
||||||
self._pending_scan = False
|
self._pending_scan = False
|
||||||
if response.status != "success":
|
if response.status != "success":
|
||||||
return
|
return
|
||||||
|
|
@ -148,54 +211,368 @@ class DougBrain(QObject):
|
||||||
self._behaviors.nearby_signs = data.get("signs", [])
|
self._behaviors.nearby_signs = data.get("signs", [])
|
||||||
self._behaviors.nearby_blocks = data.get("blocks", {})
|
self._behaviors.nearby_blocks = data.get("blocks", {})
|
||||||
|
|
||||||
# Split entities into hostiles and others
|
|
||||||
entities = data.get("entities", [])
|
entities = data.get("entities", [])
|
||||||
self._behaviors.nearby_entities = entities
|
self._behaviors.nearby_entities = entities
|
||||||
self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)]
|
self._behaviors.nearby_hostiles = [e for e in entities if e.get("isHostile", False)]
|
||||||
|
|
||||||
|
# Update memory with what we see
|
||||||
|
self._memory.update_from_scan(
|
||||||
|
position=self._behaviors.position,
|
||||||
|
blocks=self._behaviors.nearby_blocks,
|
||||||
|
containers=self._behaviors.nearby_containers,
|
||||||
|
players=self._behaviors.nearby_players,
|
||||||
|
hostiles=self._behaviors.nearby_hostiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update needs based on what we see
|
||||||
|
self._needs.on_scan(
|
||||||
|
hostiles_nearby=len(self._behaviors.nearby_hostiles),
|
||||||
|
players_nearby=len(self._behaviors.nearby_players),
|
||||||
|
)
|
||||||
|
|
||||||
def _on_inventory(self, response: ResponseMessage):
|
def _on_inventory(self, response: ResponseMessage):
|
||||||
"""Process inventory response."""
|
|
||||||
if response.status != "success":
|
if response.status != "success":
|
||||||
return
|
return
|
||||||
self._behaviors.inventory = response.data.get("items", [])
|
self._behaviors.inventory = response.data.get("items", [])
|
||||||
|
|
||||||
def _generate_tasks(self):
|
# ── Decision Engine ──
|
||||||
"""Ask behavior engine to generate tasks based on current state."""
|
|
||||||
# Priority order: survival → combat → social → exploration → organization → idle
|
|
||||||
generators = [
|
|
||||||
self._behaviors.get_survival_task,
|
|
||||||
self._behaviors.get_combat_task,
|
|
||||||
self._behaviors.get_social_task,
|
|
||||||
self._behaviors.get_exploration_task,
|
|
||||||
self._behaviors.get_organization_task,
|
|
||||||
self._behaviors.get_idle_task,
|
|
||||||
]
|
|
||||||
|
|
||||||
for gen in generators:
|
def _decide(self) -> Task | None:
|
||||||
task = gen()
|
"""
|
||||||
|
The core decision: what should Doug do RIGHT NOW?
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Critical survival (flee, eat) — if safety/hunger need is critical
|
||||||
|
2. Player commands (already in queue at HIGH priority)
|
||||||
|
3. Urgent needs (shelter at night, social when lonely)
|
||||||
|
4. Current goal step (work toward long-term goals)
|
||||||
|
5. Daily routine activity
|
||||||
|
6. Idle behavior (look around, chat)
|
||||||
|
"""
|
||||||
|
b = self._behaviors
|
||||||
|
|
||||||
|
# 1. CRITICAL SURVIVAL — safety need below 20
|
||||||
|
if self._needs.safety < 20:
|
||||||
|
task = self._survival_task()
|
||||||
if task:
|
if task:
|
||||||
should_execute = self._tasks.add(task)
|
return task
|
||||||
if should_execute:
|
|
||||||
break # High-priority task added, execute immediately
|
# 2. HUNGER — food need below 25
|
||||||
|
if self._needs.hunger < 25:
|
||||||
|
task = self._eat_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 3. SHELTER — at night with low shelter need
|
||||||
|
if b.is_night and self._needs.shelter < 30:
|
||||||
|
task = self._shelter_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 4. SOCIAL — respond to nearby players if social need is low
|
||||||
|
if self._needs.social < 30 and b.nearby_players:
|
||||||
|
task = self._social_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 5. GOAL PROGRESS — work on the current goal
|
||||||
|
task = self._goal_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 6. DAILY ROUTINE — what should Doug be doing at this time of day?
|
||||||
|
task = self._routine_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 7. BOREDOM — do something interesting
|
||||||
|
if self._needs.boredom < 30:
|
||||||
|
task = self._boredom_task()
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
|
||||||
|
# 8. IDLE — look around, maybe chat
|
||||||
|
return self._idle_task()
|
||||||
|
|
||||||
|
# ── Need-driven tasks ──
|
||||||
|
|
||||||
|
def _survival_task(self) -> Task | None:
|
||||||
|
"""Handle immediate survival threats."""
|
||||||
|
b = self._behaviors
|
||||||
|
|
||||||
|
# Flee from nearby hostiles
|
||||||
|
hostile = self._nearest_hostile(12)
|
||||||
|
if hostile:
|
||||||
|
return self._flee_task(hostile)
|
||||||
|
|
||||||
|
# Critical health with no hostiles — stay still and eat if possible
|
||||||
|
if b.health <= 6:
|
||||||
|
food = self._find_food()
|
||||||
|
if food:
|
||||||
|
return Task(
|
||||||
|
name="emergency_eat",
|
||||||
|
priority=Priority.CRITICAL,
|
||||||
|
action="equip_item",
|
||||||
|
params={"name": food, "destination": "hand"},
|
||||||
|
description=f"Emergency eating {food}",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _eat_task(self) -> Task | None:
|
||||||
|
"""Find and eat food."""
|
||||||
|
food = self._find_food()
|
||||||
|
if food:
|
||||||
|
return Task(
|
||||||
|
name="eat",
|
||||||
|
priority=Priority.URGENT,
|
||||||
|
action="equip_item",
|
||||||
|
params={"name": food, "destination": "hand"},
|
||||||
|
description=f"Eating {food}",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
# No food — add a goal to find some
|
||||||
|
if not self._goals.has_goal("find_food"):
|
||||||
|
self._goals.add_goal("find_food", priority=8)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _shelter_task(self) -> Task | None:
|
||||||
|
"""Seek shelter at night."""
|
||||||
|
home = self._memory.home
|
||||||
|
if not home:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dist = self._distance_to(home)
|
||||||
|
if dist > 10:
|
||||||
|
return Task(
|
||||||
|
name="go_home",
|
||||||
|
priority=Priority.URGENT,
|
||||||
|
action="move_to",
|
||||||
|
params={"x": home["x"], "y": home["y"], "z": home["z"], "range": 3},
|
||||||
|
description="Heading home for the night",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
# Already near home — shelter need satisfied
|
||||||
|
self._needs.shelter = min(100, self._needs.shelter + 30)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _social_task(self) -> Task | None:
|
||||||
|
"""Interact with nearby players."""
|
||||||
|
b = self._behaviors
|
||||||
|
if not b.nearby_players:
|
||||||
|
return None
|
||||||
|
|
||||||
|
player = b.nearby_players[0]
|
||||||
|
dist = player.get("distance", 99)
|
||||||
|
name = player.get("name", "player")
|
||||||
|
|
||||||
|
# Walk toward player if far
|
||||||
|
if dist > 5:
|
||||||
|
return Task(
|
||||||
|
name=f"approach_{name}",
|
||||||
|
priority=Priority.NORMAL,
|
||||||
|
action="move_to",
|
||||||
|
params={**player["position"], "range": 3},
|
||||||
|
description=f"Walking toward {name}",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close enough — say hi (throttled)
|
||||||
|
if time.time() - self._last_chat_time > 45:
|
||||||
|
self._last_chat_time = time.time()
|
||||||
|
self._needs.social = min(100, self._needs.social + 40)
|
||||||
|
context = self._build_chat_context()
|
||||||
|
self.wants_ai_chat.emit(
|
||||||
|
context,
|
||||||
|
f"You notice {name} nearby. Say something friendly and short."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Social need partially satisfied just by being near people
|
||||||
|
self._needs.social = min(100, self._needs.social + 10)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _goal_task(self) -> Task | None:
|
||||||
|
"""Get the next step from the active goal."""
|
||||||
|
goal = self._goals.get_active_goal()
|
||||||
|
if not goal:
|
||||||
|
return None
|
||||||
|
|
||||||
|
step = self._goals.get_next_step(goal, self._behaviors, self._memory)
|
||||||
|
if not step:
|
||||||
|
# Goal complete or stuck — mark it done
|
||||||
|
self._goals.complete_goal(goal["name"])
|
||||||
|
self._needs.boredom = min(100, self._needs.boredom + 20)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return step
|
||||||
|
|
||||||
|
def _routine_task(self) -> Task | None:
|
||||||
|
"""Get a task from the daily routine."""
|
||||||
|
b = self._behaviors
|
||||||
|
phase = self._routine.get_phase(b.day_time)
|
||||||
|
|
||||||
|
if phase == "morning":
|
||||||
|
# Morning: look around, plan the day
|
||||||
|
if not self._goals.has_any_goals():
|
||||||
|
self._goals.seed_initial_goals(self._memory, b)
|
||||||
|
# Look around to survey
|
||||||
|
if random.random() < 0.3:
|
||||||
|
return self._look_around_task()
|
||||||
|
|
||||||
|
elif phase == "day":
|
||||||
|
# Daytime: if no active goal, pick one based on what we know
|
||||||
|
if not self._goals.has_any_goals():
|
||||||
|
self._goals.generate_goal_from_environment(self._memory, b)
|
||||||
|
# Fall through to goal_task on next tick
|
||||||
|
|
||||||
|
elif phase == "evening":
|
||||||
|
# Evening: head toward home
|
||||||
|
home = self._memory.home
|
||||||
|
if home and self._distance_to(home) > 15:
|
||||||
|
return Task(
|
||||||
|
name="evening_return",
|
||||||
|
priority=Priority.NORMAL,
|
||||||
|
action="move_to",
|
||||||
|
params={"x": home["x"], "y": home["y"], "z": home["z"], "range": 3},
|
||||||
|
description="Heading home for the evening",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif phase == "night":
|
||||||
|
# Night: stay near home, look around cautiously
|
||||||
|
if random.random() < 0.2:
|
||||||
|
return self._look_around_task()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _boredom_task(self) -> Task | None:
|
||||||
|
"""Do something interesting to combat boredom."""
|
||||||
|
b = self._behaviors
|
||||||
|
|
||||||
|
# Check nearby interesting things from memory
|
||||||
|
interesting = self._memory.get_nearest_unexplored(b.position, max_dist=30)
|
||||||
|
if interesting:
|
||||||
|
self._needs.boredom = min(100, self._needs.boredom + 15)
|
||||||
|
return Task(
|
||||||
|
name=f"explore_{interesting['type']}",
|
||||||
|
priority=Priority.LOW,
|
||||||
|
action="move_to",
|
||||||
|
params={"x": interesting["x"], "y": interesting["y"],
|
||||||
|
"z": interesting["z"], "range": 2},
|
||||||
|
description=f"Checking out a {interesting['type']}",
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing interesting — explore in a new direction
|
||||||
|
return self._explore_task()
|
||||||
|
|
||||||
|
def _idle_task(self) -> Task | None:
|
||||||
|
"""Idle behavior — look around or chat."""
|
||||||
|
b = self._behaviors
|
||||||
|
|
||||||
|
# Chatty behavior near players
|
||||||
|
if b.nearby_players and time.time() - self._last_chat_time > 60:
|
||||||
|
if random.random() < 0.15:
|
||||||
|
self._last_chat_time = time.time()
|
||||||
|
context = self._build_chat_context()
|
||||||
|
self.wants_ai_chat.emit(context, "Say something casual and short.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look around
|
||||||
|
if random.random() < 0.4:
|
||||||
|
return self._look_around_task()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Task factories ──
|
||||||
|
|
||||||
|
def _flee_task(self, hostile: dict) -> Task:
|
||||||
|
"""Run away from a hostile mob."""
|
||||||
|
hpos = hostile.get("position", self._behaviors.position)
|
||||||
|
dx = self._behaviors.position["x"] - hpos.get("x", 0)
|
||||||
|
dz = self._behaviors.position["z"] - hpos.get("z", 0)
|
||||||
|
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
||||||
|
|
||||||
|
flee_dist = 15
|
||||||
|
flee_x = self._behaviors.position["x"] + (dx / dist) * flee_dist
|
||||||
|
flee_z = self._behaviors.position["z"] + (dz / dist) * flee_dist
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
name="flee",
|
||||||
|
priority=Priority.CRITICAL,
|
||||||
|
action="move_to",
|
||||||
|
params={"x": flee_x, "y": self._behaviors.position["y"],
|
||||||
|
"z": flee_z, "range": 3},
|
||||||
|
description=f"Fleeing from {hostile.get('type', 'mob')}!",
|
||||||
|
timeout=15,
|
||||||
|
interruptible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _explore_task(self) -> Task:
|
||||||
|
"""Explore in a direction we haven't been."""
|
||||||
|
b = self._behaviors
|
||||||
|
curiosity = b._traits.get("curiosity", 50)
|
||||||
|
|
||||||
|
# Pick a direction biased away from explored areas
|
||||||
|
angle = self._memory.suggest_explore_angle(b.position)
|
||||||
|
dist = random.uniform(8, 12 + curiosity // 10)
|
||||||
|
|
||||||
|
target_x = b.position["x"] + math.cos(angle) * dist
|
||||||
|
target_z = b.position["z"] + math.sin(angle) * dist
|
||||||
|
|
||||||
|
# Don't go too far from home
|
||||||
|
home = self._memory.home or b.spawn_pos
|
||||||
|
max_radius = 40 + curiosity // 2
|
||||||
|
dx = target_x - home["x"]
|
||||||
|
dz = target_z - home["z"]
|
||||||
|
if math.sqrt(dx * dx + dz * dz) > max_radius:
|
||||||
|
angle = math.atan2(home["z"] - b.position["z"],
|
||||||
|
home["x"] - b.position["x"])
|
||||||
|
target_x = b.position["x"] + math.cos(angle) * 8
|
||||||
|
target_z = b.position["z"] + math.sin(angle) * 8
|
||||||
|
|
||||||
|
self._needs.boredom = min(100, self._needs.boredom + 10)
|
||||||
|
return Task(
|
||||||
|
name="explore",
|
||||||
|
priority=Priority.IDLE,
|
||||||
|
action="move_to",
|
||||||
|
params={"x": target_x, "y": b.position["y"], "z": target_z, "range": 2},
|
||||||
|
description="Exploring",
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _look_around_task(self) -> Task:
|
||||||
|
"""Look in a random direction."""
|
||||||
|
b = self._behaviors
|
||||||
|
return Task(
|
||||||
|
name="look_around",
|
||||||
|
priority=Priority.IDLE,
|
||||||
|
action="look_at",
|
||||||
|
params={
|
||||||
|
"x": b.position["x"] + random.uniform(-20, 20),
|
||||||
|
"y": b.position["y"] + random.uniform(-3, 5),
|
||||||
|
"z": b.position["z"] + random.uniform(-20, 20),
|
||||||
|
},
|
||||||
|
description="",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Task execution ──
|
||||||
|
|
||||||
def _execute_next_task(self):
|
def _execute_next_task(self):
|
||||||
"""Execute the highest priority task."""
|
"""Execute the highest priority task from the queue."""
|
||||||
task = self._tasks.next()
|
task = self._tasks.next()
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Special callbacks
|
# Handle special callbacks
|
||||||
if task.callback == "on_idle_chat":
|
if task.callback == "on_idle_chat":
|
||||||
self._handle_idle_chat(task)
|
self._handle_idle_chat(task)
|
||||||
self._tasks.complete()
|
self._tasks.complete()
|
||||||
return
|
return
|
||||||
|
|
||||||
if task.callback == "on_container_opened":
|
# Skip placeholder actions
|
||||||
# Move to container first, then open it
|
|
||||||
self._execute_action(task)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Skip "status" placeholder actions
|
|
||||||
if task.action == "status":
|
if task.action == "status":
|
||||||
self._tasks.complete()
|
self._tasks.complete()
|
||||||
return
|
return
|
||||||
|
|
@ -204,21 +581,20 @@ class DougBrain(QObject):
|
||||||
if task.description and task.priority >= Priority.LOW:
|
if task.description and task.priority >= Priority.LOW:
|
||||||
log.info(f"[{task.priority.name}] {task.description}")
|
log.info(f"[{task.priority.name}] {task.description}")
|
||||||
|
|
||||||
# Execute the action
|
|
||||||
self._execute_action(task)
|
self._execute_action(task)
|
||||||
|
|
||||||
def _execute_action(self, task: Task):
|
def _execute_action(self, task: Task):
|
||||||
"""Send an action to the bridge."""
|
"""Send an action to the bridge and mark brain as busy."""
|
||||||
self._waiting_for_action = True
|
self._state = BrainState.EXECUTING_TASK
|
||||||
self._action_sent_time = time.time()
|
self._action_sent_time = time.time()
|
||||||
|
|
||||||
def on_response(resp: ResponseMessage):
|
def on_response(resp: ResponseMessage):
|
||||||
if resp.status == "success":
|
if resp.status == "success":
|
||||||
data = resp.data or {}
|
data = resp.data or {}
|
||||||
|
|
||||||
# Handle craft results specifically
|
# Craft results
|
||||||
if task.action == "craft_item":
|
if task.action == "craft_item":
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
if data.get("crafted"):
|
if data.get("crafted"):
|
||||||
item = data.get("item", "item").replace("_", " ")
|
item = data.get("item", "item").replace("_", " ")
|
||||||
self._ws.send_request("send_chat", {
|
self._ws.send_request("send_chat", {
|
||||||
|
|
@ -231,51 +607,115 @@ class DougBrain(QObject):
|
||||||
self._tasks.cancel()
|
self._tasks.cancel()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle other results with error messages
|
# Instant-complete actions
|
||||||
if task.action in ("open_chest", "dig_block", "equip_item"):
|
if task.action in ("open_chest", "dig_block", "equip_item",
|
||||||
self._waiting_for_action = False
|
"look_at", "send_chat", "attack_nearest_hostile"):
|
||||||
|
self._state = BrainState.IDLE
|
||||||
self._tasks.complete()
|
self._tasks.complete()
|
||||||
return
|
return
|
||||||
|
|
||||||
# For non-movement actions, complete immediately
|
# Movement actions wait for movement_complete event
|
||||||
if task.action not in ("move_to", "move_relative", "follow_player"):
|
if task.action not in ("move_to", "move_relative", "follow_player"):
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
self._tasks.complete()
|
self._tasks.complete()
|
||||||
else:
|
else:
|
||||||
self._waiting_for_action = False
|
self._state = BrainState.IDLE
|
||||||
self._tasks.cancel()
|
self._tasks.cancel()
|
||||||
error = resp.error or "Something went wrong"
|
error = resp.error or "Something went wrong"
|
||||||
log.debug(f"Action failed: {error}")
|
log.debug(f"Action failed: {error}")
|
||||||
# Report failure to chat for player-initiated tasks
|
|
||||||
if task.priority >= Priority.HIGH:
|
if task.priority >= Priority.HIGH:
|
||||||
self._ws.send_request("send_chat", {"message": error})
|
self._ws.send_request("send_chat", {"message": error})
|
||||||
|
|
||||||
self._ws.send_request(task.action, task.params, on_response)
|
self._ws.send_request(task.action, task.params, on_response)
|
||||||
|
|
||||||
def _handle_idle_chat(self, task: Task):
|
# ── Helpers ──
|
||||||
"""Handle unprompted chat — ask AI what to say."""
|
|
||||||
# Build context about what's happening
|
|
||||||
context_parts = []
|
|
||||||
if self._behaviors.nearby_players:
|
|
||||||
names = [p["name"] for p in self._behaviors.nearby_players]
|
|
||||||
context_parts.append(f"Players nearby: {', '.join(names)}")
|
|
||||||
if self._behaviors.is_night:
|
|
||||||
context_parts.append("It's nighttime")
|
|
||||||
if self._behaviors.is_raining:
|
|
||||||
context_parts.append("It's raining")
|
|
||||||
if self._behaviors.health < 10:
|
|
||||||
context_parts.append(f"Health is low ({self._behaviors.health})")
|
|
||||||
if self._behaviors.nearby_hostiles:
|
|
||||||
types = [h["type"] for h in self._behaviors.nearby_hostiles[:3]]
|
|
||||||
context_parts.append(f"Nearby mobs: {', '.join(types)}")
|
|
||||||
|
|
||||||
context = "; ".join(context_parts) if context_parts else "Nothing special happening"
|
def _handle_idle_chat(self, task: Task):
|
||||||
self.wants_ai_chat.emit(context, "Say something to the players nearby. Keep it natural and short.")
|
"""Handle unprompted chat."""
|
||||||
|
context = self._build_chat_context()
|
||||||
|
self.wants_ai_chat.emit(
|
||||||
|
context,
|
||||||
|
"Say something to the players nearby. Keep it natural and short."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_chat_context(self) -> str:
|
||||||
|
"""Build a context string describing what's happening."""
|
||||||
|
parts = []
|
||||||
|
b = self._behaviors
|
||||||
|
if b.nearby_players:
|
||||||
|
names = [p["name"] for p in b.nearby_players]
|
||||||
|
parts.append(f"Players nearby: {', '.join(names)}")
|
||||||
|
if b.is_night:
|
||||||
|
parts.append("It's nighttime")
|
||||||
|
if b.is_raining:
|
||||||
|
parts.append("It's raining")
|
||||||
|
if b.health < 10:
|
||||||
|
parts.append(f"Health is low ({b.health})")
|
||||||
|
if b.nearby_hostiles:
|
||||||
|
types = [h["type"] for h in b.nearby_hostiles[:3]]
|
||||||
|
parts.append(f"Nearby mobs: {', '.join(types)}")
|
||||||
|
|
||||||
|
# Add goal context
|
||||||
|
goal = self._goals.get_active_goal()
|
||||||
|
if goal:
|
||||||
|
parts.append(f"Currently working on: {goal['description']}")
|
||||||
|
|
||||||
|
# Add need context
|
||||||
|
critical_needs = self._needs.get_critical_needs()
|
||||||
|
if critical_needs:
|
||||||
|
parts.append(f"Feeling: {', '.join(critical_needs)}")
|
||||||
|
|
||||||
|
return "; ".join(parts) if parts else "Nothing special happening"
|
||||||
|
|
||||||
|
def _nearest_hostile(self, max_dist: float) -> dict | None:
|
||||||
|
closest = None
|
||||||
|
closest_dist = max_dist
|
||||||
|
for h in self._behaviors.nearby_hostiles:
|
||||||
|
d = h.get("distance", 99)
|
||||||
|
if d < closest_dist:
|
||||||
|
closest = h
|
||||||
|
closest_dist = d
|
||||||
|
return closest
|
||||||
|
|
||||||
|
def _distance_to(self, pos: dict) -> float:
|
||||||
|
b = self._behaviors.position
|
||||||
|
dx = b["x"] - pos.get("x", 0)
|
||||||
|
dy = b["y"] - pos.get("y", 0)
|
||||||
|
dz = b["z"] - pos.get("z", 0)
|
||||||
|
return math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
|
||||||
|
def _find_food(self) -> str | None:
|
||||||
|
"""Find food in inventory."""
|
||||||
|
food_items = {
|
||||||
|
"cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
|
||||||
|
"cooked_rabbit", "cooked_salmon", "cooked_cod", "bread", "apple",
|
||||||
|
"golden_apple", "melon_slice", "sweet_berries", "baked_potato",
|
||||||
|
"mushroom_stew", "beetroot_soup", "rabbit_stew", "cookie",
|
||||||
|
"pumpkin_pie", "cake", "dried_kelp", "carrot", "potato",
|
||||||
|
}
|
||||||
|
for item in self._behaviors.inventory:
|
||||||
|
if item.get("name", "").replace("minecraft:", "") in food_items:
|
||||||
|
return item["name"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_sheltered(self) -> bool:
|
||||||
|
"""Check if near home/shelter."""
|
||||||
|
home = self._memory.home
|
||||||
|
if not home:
|
||||||
|
return False
|
||||||
|
return self._distance_to(home) < 15
|
||||||
|
|
||||||
|
# ── Public interface (same as before) ──
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_action(self) -> str:
|
def current_action(self) -> str:
|
||||||
task = self._tasks.current_task
|
task = self._tasks.current_task
|
||||||
return task.name if task else "idle"
|
if task:
|
||||||
|
return task.description or task.name
|
||||||
|
goal = self._goals.get_active_goal()
|
||||||
|
if goal:
|
||||||
|
return goal["description"]
|
||||||
|
return "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_night(self) -> bool:
|
def is_night(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class CommandParser:
|
||||||
|
|
||||||
CRAFT_PATTERNS = [
|
CRAFT_PATTERNS = [
|
||||||
r"(?:craft|make|build|create)\s+(.+)",
|
r"(?:craft|make|build|create)\s+(.+)",
|
||||||
|
r"(?:can you|could you|please)\s+(?:craft|make|build|create)\s+(.+)",
|
||||||
]
|
]
|
||||||
|
|
||||||
MINE_PATTERNS = [
|
MINE_PATTERNS = [
|
||||||
|
|
@ -161,21 +162,41 @@ class CommandParser:
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Number words for quantity parsing
|
||||||
|
NUMBER_WORDS = {
|
||||||
|
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
||||||
|
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
|
||||||
|
"a": 1, "an": 1,
|
||||||
|
}
|
||||||
|
|
||||||
def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||||
for pattern in self.CRAFT_PATTERNS:
|
for pattern in self.CRAFT_PATTERNS:
|
||||||
match = re.search(pattern, msg, re.IGNORECASE)
|
match = re.search(pattern, msg, re.IGNORECASE)
|
||||||
if match:
|
if match:
|
||||||
raw_item = match.group(1).strip() if match.lastindex else ""
|
raw_item = match.group(match.lastindex).strip() if match.lastindex else ""
|
||||||
# Extract item name: take first 1-3 non-filler words before any preposition
|
# Extract quantity and item name
|
||||||
filler = {"a", "an", "some", "me", "the", "this", "that", "please"}
|
filler = {"some", "me", "the", "this", "that", "please", "of"}
|
||||||
stop_words = {"with", "from", "using", "in", "on", "at", "for", "out", "of"}
|
stop_words = {"with", "from", "using", "in", "on", "at", "for", "out"}
|
||||||
words = []
|
words = []
|
||||||
|
count = 1
|
||||||
for w in raw_item.split():
|
for w in raw_item.split():
|
||||||
wl = w.lower().rstrip(".,!?")
|
wl = w.lower().rstrip(".,!?")
|
||||||
if wl in filler:
|
if wl in filler:
|
||||||
continue
|
continue
|
||||||
if wl in stop_words:
|
if wl in stop_words:
|
||||||
break # Stop at prepositions
|
break # Stop at prepositions
|
||||||
|
# Check for quantity (number or number word)
|
||||||
|
if not words:
|
||||||
|
if wl.isdigit():
|
||||||
|
count = int(wl)
|
||||||
|
continue
|
||||||
|
if wl in self.NUMBER_WORDS:
|
||||||
|
count = self.NUMBER_WORDS[wl]
|
||||||
|
continue
|
||||||
|
# "a" and "an" are quantity 1 when before the item
|
||||||
|
if not words and wl in ("a", "an"):
|
||||||
|
count = 1
|
||||||
|
continue
|
||||||
words.append(wl)
|
words.append(wl)
|
||||||
if len(words) >= 3:
|
if len(words) >= 3:
|
||||||
break
|
break
|
||||||
|
|
@ -185,6 +206,7 @@ class CommandParser:
|
||||||
return ParsedCommand(
|
return ParsedCommand(
|
||||||
action="craft",
|
action="craft",
|
||||||
target=item,
|
target=item,
|
||||||
|
params={"count": count},
|
||||||
raw_message=msg,
|
raw_message=msg,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
@ -284,13 +306,14 @@ def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]:
|
||||||
)
|
)
|
||||||
|
|
||||||
elif cmd.action == "craft":
|
elif cmd.action == "craft":
|
||||||
|
count = cmd.params.get("count", 1) if cmd.params else 1
|
||||||
return Task(
|
return Task(
|
||||||
name=f"craft_{cmd.target}",
|
name=f"craft_{cmd.target}",
|
||||||
priority=Priority.HIGH,
|
priority=Priority.HIGH,
|
||||||
action="craft_item",
|
action="craft_item",
|
||||||
params={"itemName": cmd.target, "count": 1},
|
params={"itemName": cmd.target, "count": count},
|
||||||
description=f"Crafting {cmd.target.replace('_', ' ')}",
|
description=f"Crafting {count}x {cmd.target.replace('_', ' ')}",
|
||||||
timeout=15,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif cmd.action == "mine":
|
elif cmd.action == "mine":
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,24 @@ class MainWindow(QMainWindow):
|
||||||
f"Ollama server not reachable at {self.config.ollama_url}"
|
f"Ollama server not reachable at {self.config.ollama_url}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pre-warm the model in background so the first real chat is fast
|
||||||
|
model = self.config.get("ollama_model", "")
|
||||||
|
if model and self._ollama:
|
||||||
|
self.dashboard.log_viewer.append_system(
|
||||||
|
f"Pre-warming Ollama model '{model}'..."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_warm_done(success, _ollama=self._ollama, _model=model):
|
||||||
|
if success:
|
||||||
|
log.info("Ollama model pre-warmed successfully")
|
||||||
|
# Start keep-alive pings to prevent model unload
|
||||||
|
if _ollama and not _ollama._closed:
|
||||||
|
_ollama.start_keepalive(_model)
|
||||||
|
else:
|
||||||
|
log.warning("Failed to pre-warm Ollama model (will retry on first chat)")
|
||||||
|
|
||||||
|
self._ollama.warm_model_async(model, callback=_on_warm_done)
|
||||||
|
|
||||||
# Start Node.js bridge
|
# Start Node.js bridge
|
||||||
ws_port = self._ws_port_counter
|
ws_port = self._ws_port_counter
|
||||||
self._ws_port_counter += 1
|
self._ws_port_counter += 1
|
||||||
|
|
@ -232,6 +250,7 @@ class MainWindow(QMainWindow):
|
||||||
self._node_manager = None
|
self._node_manager = None
|
||||||
|
|
||||||
if self._ollama:
|
if self._ollama:
|
||||||
|
self._ollama.stop_keepalive()
|
||||||
self._ollama.close()
|
self._ollama.close()
|
||||||
self._ollama = None
|
self._ollama = None
|
||||||
|
|
||||||
|
|
@ -476,9 +495,8 @@ class MainWindow(QMainWindow):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _generate_response(self, sender: str, message: str):
|
def _generate_response(self, sender: str, message: str):
|
||||||
"""Generate an AI response to a chat message."""
|
"""Generate an AI response to a chat message using OllamaClient."""
|
||||||
import time as _time
|
import threading
|
||||||
import httpx
|
|
||||||
|
|
||||||
if not self._active_doug:
|
if not self._active_doug:
|
||||||
return
|
return
|
||||||
|
|
@ -490,6 +508,10 @@ class MainWindow(QMainWindow):
|
||||||
self.dashboard.log_viewer.append_error("No Ollama model configured!")
|
self.dashboard.log_viewer.append_error("No Ollama model configured!")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self._ollama:
|
||||||
|
self.dashboard.log_viewer.append_error("Ollama client not initialized!")
|
||||||
|
return
|
||||||
|
|
||||||
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})")
|
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})")
|
||||||
|
|
||||||
# Build context from brain state
|
# Build context from brain state
|
||||||
|
|
@ -512,51 +534,34 @@ class MainWindow(QMainWindow):
|
||||||
custom_notes=doug.custom_notes,
|
custom_notes=doug.custom_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare messages
|
# Build chat history from recent messages
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
chat_history = []
|
||||||
# Add last few chat messages for context
|
|
||||||
recent = self._chat_repo.get_recent(doug.id, limit=3)
|
recent = self._chat_repo.get_recent(doug.id, limit=3)
|
||||||
for msg in reversed(recent):
|
for msg in reversed(recent):
|
||||||
role = "assistant" if msg["sender"] == doug.name else "user"
|
role = "assistant" if msg["sender"] == doug.name else "user"
|
||||||
messages.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"})
|
chat_history.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"})
|
||||||
messages.append({"role": "user", "content": f"{sender}: {message}"})
|
|
||||||
|
|
||||||
ollama_url = self.config.ollama_url
|
user_message = f"{sender}: {message}"
|
||||||
doug_id = doug.id
|
doug_id = doug.id
|
||||||
doug_name = doug.name
|
doug_name = doug.name
|
||||||
|
ollama = self._ollama
|
||||||
# Direct HTTP call in a thread — same approach as terminal test
|
|
||||||
import threading
|
|
||||||
|
|
||||||
def _do_request():
|
def _do_request():
|
||||||
t0 = _time.time()
|
reply = ollama.chat(
|
||||||
try:
|
model=model,
|
||||||
client = httpx.Client(timeout=30.0)
|
system_prompt=system_prompt,
|
||||||
resp = client.post(f"{ollama_url}/api/chat", json={
|
user_message=user_message,
|
||||||
"model": model,
|
chat_history=chat_history,
|
||||||
"messages": messages,
|
temperature=0.8,
|
||||||
"stream": False,
|
num_predict=25,
|
||||||
"think": False,
|
)
|
||||||
"options": {"temperature": 0.8, "num_predict": 25},
|
|
||||||
})
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
reply = data.get("message", {}).get("content", "").strip()
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
elapsed = _time.time() - t0
|
|
||||||
log.info(f"Ollama responded in {elapsed:.1f}s: {reply[:60]}")
|
|
||||||
|
|
||||||
if reply:
|
if reply:
|
||||||
# Emit signal to handle response on main thread
|
|
||||||
self._chat_response_ready.emit(reply, doug_id, doug_name)
|
self._chat_response_ready.emit(reply, doug_id, doug_name)
|
||||||
else:
|
else:
|
||||||
log.error("Empty response from Ollama")
|
log.error("Ollama returned no response (after retry)")
|
||||||
|
|
||||||
except Exception as e:
|
thread = threading.Thread(target=_do_request, daemon=True, name="ollama-chat")
|
||||||
log.error(f"Ollama request failed: {e}")
|
|
||||||
|
|
||||||
thread = threading.Thread(target=_do_request, daemon=True)
|
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _send_chat_response(self, response: str, doug_id: int, doug_name: str):
|
def _send_chat_response(self, response: str, doug_id: int, doug_name: str):
|
||||||
|
|
|
||||||
425
test-combat.js
Normal file
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