Fix combat loop, add equipment management

Combat:
- Track entityGone event to detect mob death (not just entity check)
- Auto-equip best weapon before fighting
- 10-second cooldown between engagements (was 5)
- Better target death detection

Equipment system:
- equipBestWeapon(): finds and equips highest-tier sword/axe
- equipBestTool(blockType): picks right tool for block (pickaxe for stone, etc.)
- equipBestArmor(): equips best armor in each slot
- Auto-equip armor when new armor pieces appear in inventory
- Weapon tiers: netherite > diamond > iron > golden > stone > wooden
- Tool type mapping: pickaxe for ores, axe for wood, shovel for dirt

New bridge actions: equip_best_weapon, equip_best_tool, equip_armor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 17:39:47 -05:00
parent 413d18d997
commit c2b996947a
2 changed files with 199 additions and 17 deletions

View file

@ -456,6 +456,130 @@ function isHostile(entity) {
} }
// --- WebSocket Action Handlers --- // --- WebSocket Action Handlers ---
// --- Equipment Management Helpers ---
// Weapon tiers (higher = better)
const WEAPON_TIERS = {
'netherite_sword': 10, 'diamond_sword': 9, 'iron_sword': 8,
'golden_sword': 6, 'stone_sword': 5, 'wooden_sword': 4,
'netherite_axe': 9, 'diamond_axe': 8, 'iron_axe': 7,
'golden_axe': 5, 'stone_axe': 4, 'wooden_axe': 3,
};
// Tool type → block types it's best for
const TOOL_FOR_BLOCK = {
'pickaxe': ['stone', 'cobblestone', 'ore', 'iron', 'gold', 'diamond', 'netherite', 'brick', 'obsidian', 'deepslate', 'copper'],
'axe': ['log', 'planks', 'wood', 'fence', 'door', 'sign', 'bookshelf', 'chest', 'crafting_table', 'barrel'],
'shovel': ['dirt', 'grass', 'sand', 'gravel', 'clay', 'soul_sand', 'snow', 'mud', 'farmland'],
'hoe': ['hay', 'sponge', 'leaves', 'sculk', 'moss'],
};
const TOOL_TIERS = ['netherite', 'diamond', 'iron', 'golden', 'stone', 'wooden'];
// Armor slots and tiers
const ARMOR_SLOTS = {
'helmet': 'head', 'chestplate': 'torso', 'leggings': 'legs', 'boots': 'feet',
};
const ARMOR_TIERS = ['netherite', 'diamond', 'iron', 'golden', 'chainmail', 'leather'];
async function equipBestWeapon() {
const items = bot.inventory.items();
let bestItem = null;
let bestTier = -1;
for (const item of items) {
const name = item.name.replace('minecraft:', '');
const tier = WEAPON_TIERS[name] || 0;
if (tier > bestTier) {
bestTier = tier;
bestItem = item;
}
}
if (bestItem && bestItem !== bot.heldItem) {
try {
await bot.equip(bestItem, 'hand');
log('client', 'INFO', `Equipped ${bestItem.name}`);
return { equipped: true, item: bestItem.name };
} catch (e) {
return { equipped: false, error: e.message };
}
}
return { equipped: false, reason: bestItem ? 'already_equipped' : 'no_weapons' };
}
async function equipBestTool(blockType) {
if (!blockType) return { equipped: false, reason: 'no_block_type' };
// Figure out which tool type is needed
let neededToolType = null;
const bt = blockType.toLowerCase();
for (const [toolType, blockTypes] of Object.entries(TOOL_FOR_BLOCK)) {
if (blockTypes.some(b => bt.includes(b))) {
neededToolType = toolType;
break;
}
}
if (!neededToolType) return { equipped: false, reason: 'no_tool_needed' };
// Find best tool of that type in inventory
const items = bot.inventory.items();
let bestItem = null;
let bestTierIdx = 999;
for (const item of items) {
const name = item.name.replace('minecraft:', '');
if (!name.includes(neededToolType)) continue;
const tierIdx = TOOL_TIERS.findIndex(t => name.startsWith(t));
if (tierIdx >= 0 && tierIdx < bestTierIdx) {
bestTierIdx = tierIdx;
bestItem = item;
}
}
if (bestItem && bestItem !== bot.heldItem) {
try {
await bot.equip(bestItem, 'hand');
log('client', 'INFO', `Equipped ${bestItem.name} for ${blockType}`);
return { equipped: true, item: bestItem.name, toolType: neededToolType };
} catch (e) {
return { equipped: false, error: e.message };
}
}
return { equipped: false, reason: bestItem ? 'already_equipped' : `no_${neededToolType}` };
}
async function equipBestArmor() {
const items = bot.inventory.items();
const equipped = [];
for (const [armorPiece, slot] of Object.entries(ARMOR_SLOTS)) {
let bestItem = null;
let bestTierIdx = 999;
for (const item of items) {
const name = item.name.replace('minecraft:', '');
if (!name.includes(armorPiece)) continue;
const tierIdx = ARMOR_TIERS.findIndex(t => name.startsWith(t));
if (tierIdx >= 0 && tierIdx < bestTierIdx) {
bestTierIdx = tierIdx;
bestItem = item;
}
}
if (bestItem) {
try {
await bot.equip(bestItem, slot);
equipped.push(bestItem.name);
} catch (e) {
log('client', 'WARN', `Failed to equip ${bestItem.name}: ${e.message}`);
}
}
}
return { equipped: equipped.length > 0, items: equipped };
}
async function handleAction(action, params = {}) { async function handleAction(action, params = {}) {
if (!spawned && action !== 'status') { if (!spawned && action !== 'status') {
throw new Error('Bot not spawned yet'); throw new Error('Bot not spawned yet');
@ -766,53 +890,103 @@ async function handleAction(action, params = {}) {
if (hostiles.length === 0) return { attacked: false, reason: 'no_hostiles' }; if (hostiles.length === 0) return { attacked: false, reason: 'no_hostiles' };
hostiles.sort((a, b) => a.dist - b.dist); hostiles.sort((a, b) => a.dist - b.dist);
const target = hostiles[0].entity; const target = hostiles[0].entity;
log('client', 'INFO', `Engaging ${target.name || target.type} (id=${target.id}, dist=${hostiles[0].dist.toFixed(1)}, gameMode=${bot.game?.gameMode})`); const targetId = target.id;
// Sustained combat: keep attacking until target is dead or out of range // Auto-equip best weapon before fighting
await equipBestWeapon();
log('client', 'INFO', `Fighting ${target.name || target.type} (id=${targetId}, dist=${hostiles[0].dist.toFixed(1)})`);
// Sustained combat loop
let hits = 0; let hits = 0;
const maxHits = 20; const maxHits = 15;
const combatPromise = new Promise((resolve) => { const combatPromise = new Promise((resolve) => {
const attackInterval = setInterval(() => { // Track if target dies via entityGone event
// Check if target is still alive and in range let targetDead = false;
const ent = bot.entities[target.id]; const onGone = (entity) => {
if (entity.id === targetId) targetDead = true;
};
bot.on('entityGone', onGone);
const attackInterval = setInterval(async () => {
// Target despawned or dead
if (targetDead) {
clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
log('client', 'INFO', `Target killed after ${hits} hits`);
resolve({ attacked: true, hits, result: 'target_killed' });
return;
}
const ent = bot.entities[targetId];
if (!ent || !ent.position) { if (!ent || !ent.position) {
clearInterval(attackInterval); clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
resolve({ attacked: true, hits, result: 'target_gone' }); resolve({ attacked: true, hits, result: 'target_gone' });
return; return;
} }
const d = ent.position.distanceTo(bot.entity.position); const d = ent.position.distanceTo(bot.entity.position);
if (d > range + 2) { if (d > range + 4) {
clearInterval(attackInterval); clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
resolve({ attacked: true, hits, result: 'out_of_range' }); resolve({ attacked: true, hits, result: 'out_of_range' });
return; return;
} }
if (hits >= maxHits) { if (hits >= maxHits) {
clearInterval(attackInterval); clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
resolve({ attacked: true, hits, result: 'max_hits' }); resolve({ attacked: true, hits, result: 'max_hits' });
return; return;
} }
// Look at target and attack // Chase if too far
bot.lookAt(ent.position.offset(0, ent.height * 0.8, 0)).then(() => {
try { bot.attack(ent); hits++; } catch (e) {}
});
// Move toward target if too far for melee
if (d > 3) { if (d > 3) {
bot.pathfinder.setGoal(new GoalNear(ent.position.x, ent.position.y, ent.position.z, 2)); bot.pathfinder.setGoal(new GoalNear(ent.position.x, ent.position.y, ent.position.z, 2));
} }
}, 500); // Attack every 500ms
// Look at and attack
try {
await bot.lookAt(ent.position.offset(0, (ent.height || 1) * 0.5, 0), true);
await bot.attack(ent);
hits++;
} catch (e) {
// Entity might have died between check and attack
clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
resolve({ attacked: true, hits, result: 'target_gone' });
}
}, 500);
// Safety timeout // Safety timeout
setTimeout(() => { setTimeout(() => {
clearInterval(attackInterval); clearInterval(attackInterval);
bot.removeListener('entityGone', onGone);
resolve({ attacked: true, hits, result: 'timeout' }); resolve({ attacked: true, hits, result: 'timeout' });
}, 10000); }, 12000);
}); });
return await combatPromise; return await combatPromise;
} }
// --- Equipment Management ---
case 'equip_best_weapon': {
const result = await equipBestWeapon();
return result;
}
case 'equip_best_tool': {
const { blockType } = params;
const result = await equipBestTool(blockType);
return result;
}
case 'equip_armor': {
const result = await equipBestArmor();
return result;
}
// --- Crafting --- // --- Crafting ---
case 'list_recipes': { case 'list_recipes': {
const { itemName } = params; const { itemName } = params;

View file

@ -222,7 +222,15 @@ class DougBrain(QObject):
def _on_inventory(self, response: ResponseMessage): def _on_inventory(self, response: ResponseMessage):
if response.status != "success": if response.status != "success":
return return
old_items = set(i.get("name", "") for i in self._behaviors.inventory)
self._behaviors.inventory = response.data.get("items", []) self._behaviors.inventory = response.data.get("items", [])
new_items = set(i.get("name", "") for i in self._behaviors.inventory)
# Auto-equip armor if we picked up new armor pieces
new_pickups = new_items - old_items
armor_words = ("helmet", "chestplate", "leggings", "boots")
if any(any(a in item for a in armor_words) for item in new_pickups):
self._ws.send_request("equip_armor", {})
# ── Interrupts (combat/flee — temporary, don't affect stack) ── # ── Interrupts (combat/flee — temporary, don't affect stack) ──
@ -232,8 +240,8 @@ class DougBrain(QObject):
if self._tasks._interruption and self._tasks._interruption.status == TaskStatus.ACTIVE: if self._tasks._interruption and self._tasks._interruption.status == TaskStatus.ACTIVE:
return return
# Cooldown after combat — don't re-engage for 5 seconds # Cooldown after combat — don't re-engage for 10 seconds
if hasattr(self, '_last_combat_time') and time.time() - self._last_combat_time < 5: if hasattr(self, '_last_combat_time') and time.time() - self._last_combat_time < 10:
return return
b = self._behaviors b = self._behaviors