feat(minecraft): introduce structured ActionError for skill failures, implement fail-fast logic in planning, and remove unused planning code.

This commit is contained in:
Rin
2026-02-18 11:09:56 +08:00
committed by Neko Ayaka
parent bb4685e083
commit 3b22a45bd3
6 changed files with 285 additions and 480 deletions
@@ -5,6 +5,7 @@ import type { PlanStep } from '../planning/adapter'
import { useBot } from '../../composables/bot'
import { AbstractAgent } from '../../libs/mineflayer/base-agent'
import { ActionError } from '../../utils/errors'
import { actionsList } from './tools'
interface ActionState {
@@ -78,6 +79,10 @@ export class ActionAgentImpl extends AbstractAgent implements ActionAgent {
})
}
catch (error) {
if (error instanceof ActionError) {
this.logger.withError(error).warn(`Action failed: ${error.code}`)
throw error
}
this.logger.withError(error).error('Action failed')
throw error
}
+15 -203
View File
@@ -5,6 +5,7 @@ import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from
import type { PlanStep } from './adapter'
import { AbstractAgent } from '../../libs/mineflayer/base-agent'
import { ActionError } from '../../utils/errors'
import { ActionAgentImpl } from '../action'
import { PlanningLLMHandler } from './adapter'
@@ -164,6 +165,19 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent {
await this.actionAgent.performAction(step)
}
catch (stepError) {
if (stepError instanceof ActionError) {
this.logger.withError(stepError).warn('Step execution failed with ActionError')
// If it's a resource failure or crafting failure that we've already tried to fix (implied by fail-fast skills),
// then we should abort and report failure instead of looping.
// We can check error types or context.
if (stepError.code === 'RESOURCE_MISSING' || stepError.code === 'CRAFTING_FAILED' || stepError.code === 'INVENTORY_FULL') {
// For now, fail fast on these hard errors.
// In the future we might want a "replanning" phase here, but NOT a blind retry.
throw stepError;
}
}
this.logger.withError(stepError).error('Failed to execute step')
// Attempt to adjust plan and retry
@@ -187,163 +201,13 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent {
}
catch (error) {
plan.status = 'failed'
throw error
throw error // This will be caught by handleChatMessage and reported to user
}
finally {
this.context = null
}
}
// private async generateStepsStream(
// goal: string,
// availableActions: Action[],
// sender: string,
// ): Promise<void> {
// if (!this.context) {
// return
// }
// try {
// // Generate all steps at once
// const steps = await this.llmHandler.generatePlan(goal, availableActions, sender)
// if (!this.context.isGenerating) {
// return
// }
// // Add all steps to pending queue
// this.context.pendingSteps.push(...steps)
// this.logger.withField('steps', steps).log('Generated steps')
// }
// catch (error) {
// this.logger.withError(error).error('Failed to generate steps')
// throw error
// }
// finally {
// this.context.isGenerating = false
// }
// }
// private async executeStepsStream(): Promise<void> {
// if (!this.context || !this.actionAgent) {
// return
// }
// try {
// while (this.context.isGenerating || this.context.pendingSteps.length > 0) {
// // Wait for steps to be available
// if (this.context.pendingSteps.length === 0) {
// await new Promise(resolve => setTimeout(resolve, 100))
// continue
// }
// // Execute next step
// const step = this.context.pendingSteps.shift()
// if (!step) {
// continue
// }
// try {
// this.logger.withField('step', step).log('Executing step')
// await this.actionAgent.performAction(step)
// this.context.lastUpdate = Date.now()
// this.context.currentStep++
// }
// catch (stepError) {
// this.logger.withError(stepError).error('Failed to execute step')
// // Attempt to adjust plan and retry
// if (this.context.retryCount < 3) {
// this.context.retryCount++
// // Stop current generation
// this.context.isGenerating = false
// this.context.pendingSteps = []
// // Adjust plan and restart
// const adjustedPlan = await this.adjustPlan(
// this.currentPlan!,
// stepError instanceof Error ? stepError.message : 'Unknown error',
// 'system',
// )
// await this.executePlan(adjustedPlan)
// return
// }
// throw stepError
// }
// }
// }
// catch (error) {
// this.logger.withError(error).error('Failed to execute steps')
// throw error
// }
// }
// private async *createStepGenerator(
// goal: string,
// availableActions: Action[],
// ): AsyncGenerator<PlanStep[], void, unknown> {
// // Use LLM to generate plan in chunks
// this.logger.log('Generating plan using LLM')
// const chunkSize = 3 // Generate 3 steps at a time
// let currentChunk = 1
// while (true) {
// const steps = await this.llmHandler.generatePlan(
// goal,
// availableActions,
// `Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`,
// )
// if (steps.length === 0) {
// break
// }
// yield steps
// currentChunk++
// // Check if we've generated enough steps or if the goal is achieved
// if (steps.length < chunkSize || await this.isGoalAchieved(goal)) {
// break
// }
// }
// }
// private async isGoalAchieved(goal: string): Promise<boolean> {
// if (!this.context || !this.actionAgent) {
// return false
// }
// const requirements = this.parseGoalRequirements(goal)
// // Check inventory for required items
// if (requirements.needsItems && requirements.items) {
// const inventorySteps = this.generateGatheringSteps(requirements.items)
// if (inventorySteps.length > 0) {
// this.context.pendingSteps.push(...inventorySteps)
// return false
// }
// }
// // Check location requirements
// if (requirements.needsMovement && requirements.location) {
// const movementSteps = this.generateMovementSteps(requirements.location)
// if (movementSteps.length > 0) {
// this.context.pendingSteps.push(...movementSteps)
// return false
// }
// }
// // Check interaction requirements
// if (requirements.needsInteraction && requirements.target) {
// const interactionSteps = this.generateInteractionSteps(requirements.target)
// if (interactionSteps.length > 0) {
// this.context.pendingSteps.push(...interactionSteps)
// return false
// }
// }
// return true
// }
public async adjustPlan(plan: Plan, feedback: string, sender: string): Promise<Plan> {
if (!this.initialized) {
throw new Error('Planning agent not initialized')
@@ -387,58 +251,6 @@ export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent {
}
}
// private generateGatheringSteps(items: string[]): PlanStep[] {
// const steps: PlanStep[] = []
// for (const item of items) {
// steps.push(
// {
// description: `Search for ${item} in the surrounding area`,
// tool: 'searchForBlock',
// params: {
// blockType: item,
// range: 64,
// },
// },
// {
// description: `Collect ${item} from the found location`,
// tool: 'collectBlocks',
// params: {
// blockType: item,
// count: 1,
// },
// },
// )
// }
// return steps
// }
// private generateMovementSteps(location: { x?: number, y?: number, z?: number }): PlanStep[] {
// if (location.x !== undefined && location.y !== undefined && location.z !== undefined) {
// return [{
// description: `Move to coordinates (${location.x}, ${location.y}, ${location.z})`,
// tool: 'goToCoordinates',
// params: {
// x: location.x,
// y: location.y,
// z: location.z,
// },
// }]
// }
// return []
// }
// private generateInteractionSteps(target: string): PlanStep[] {
// return [{
// description: `Interact with ${target}`,
// tool: 'activate',
// params: {
// target,
// },
// }]
// }
private generateRecoverySteps(feedback: string): PlanStep[] {
const steps: PlanStep[] = []
@@ -1,6 +1,6 @@
import type { Logger } from '@guiiai/logg'
import type { Neuri, NeuriContext } from 'neuri'
import type { Logger } from '../../utils/logger'
import type { MineflayerWithAgents } from './types'
import { withRetry } from '@moeru/std'
@@ -46,7 +46,7 @@ export async function handleChatMessage(username: string, message: string, bot:
}
}
catch (error) {
logger.withError(error).error('Failed to process message')
logger.withError(error).warn('Failed to process message')
bot.bot.chat(
`Sorry, I encountered an error: ${
error instanceof Error ? error.message : 'Unknown error'
+162 -177
View File
@@ -1,5 +1,6 @@
import type { Mineflayer } from '../../libs/mineflayer'
import { ActionError } from '../../utils/errors'
import { useLogger } from '../../utils/logger'
import { getItemId } from '../../utils/mcdata'
import { craftRecipe } from '../crafting'
@@ -17,32 +18,37 @@ const logger = useLogger()
export async function ensureCraftingTable(mineflayer: Mineflayer): Promise<boolean> {
logger.log('Bot: Checking for a crafting table...')
let hasCraftingTable = getItemCount(mineflayer, 'crafting_table') > 0
const hasCraftingTable = getItemCount(mineflayer, 'crafting_table') > 0
if (hasCraftingTable) {
logger.log('Bot: Crafting table is available.')
return true
}
while (!hasCraftingTable) {
// Check if we already have wood to make it
try {
const planksEnsured = await ensurePlanks(mineflayer, 4)
if (!planksEnsured) {
logger.error('Bot: Failed to ensure planks.')
continue
throw new ActionError('RESOURCE_MISSING', 'Failed to ensure planks for crafting table', { item: 'planks', count: 4 })
}
// Craft crafting table
hasCraftingTable = await craftRecipe(mineflayer, 'crafting_table', 1)
if (hasCraftingTable) {
const result = await craftRecipe(mineflayer, 'crafting_table', 1)
if (result) {
mineflayer.bot.chat('I have made a crafting table.')
logger.log('Bot: Crafting table crafted.')
}
else {
logger.error('Bot: Failed to craft crafting table.')
return true
}
}
catch (error) {
if (error instanceof ActionError) {
throw error
}
// If craftRecipe failed but didn't throw ActionError (protection for mixed versions)
throw new ActionError('CRAFTING_FAILED', 'Failed to craft crafting table', { error })
}
return hasCraftingTable
throw new ActionError('CRAFTING_FAILED', 'Failed to ensure crafting table')
}
// Helper function to ensure a specific amount of planks
@@ -51,12 +57,16 @@ export async function ensurePlanks(mineflayer: Mineflayer, neededAmount: number)
let planksCount = getItemCount(mineflayer, 'planks')
if (neededAmount < planksCount) {
if (neededAmount <= planksCount) {
logger.log('Bot: Have enough planks.')
return true
}
while (neededAmount > planksCount) {
const maxRetries = 3
let retries = 0
while (neededAmount > planksCount && retries < maxRetries) {
retries++
const logsNeeded = Math.ceil((neededAmount - planksCount) / PLANKS_PER_LOG)
// Get all available log types in inventory
@@ -66,46 +76,64 @@ export async function ensurePlanks(mineflayer: Mineflayer, neededAmount: number)
// If no logs available, gather more wood
if (availableLogs.length === 0) {
await gatherWood(mineflayer, logsNeeded, 80)
logger.error('Bot: Not enough logs for planks.')
logger.log(`Bot: Not enough logs. Gathering ${logsNeeded} logs.`)
try {
await gatherWood(mineflayer, logsNeeded, 80)
}
catch (error) {
throw new ActionError('RESOURCE_MISSING', 'Could not gather wood', { item: 'log', count: logsNeeded, originalError: error })
}
// Check if we actually got wood
const newLogs = mineflayer.bot.inventory.items().filter(item => item.name.includes('log'))
if (newLogs.length === 0) {
throw new ActionError('RESOURCE_MISSING', 'Gathered wood but inventory still empty of logs', { item: 'log' })
}
// Continue to next iteration to craft
continue
}
// Iterate over each log type to craft planks
let anyCrafted = false
for (const log of availableLogs) {
const logType = log.name.replace('_log', '') // Get log type without "_log" suffix
const logsToCraft = Math.min(log.count, logsNeeded)
logger.log(
`Trying to make ${logsToCraft * PLANKS_PER_LOG} ${logType}_planks`,
)
logger.log(`NeededAmount: ${neededAmount}, while I have ${planksCount}`)
logger.log(`Trying to make ${logsToCraft * PLANKS_PER_LOG} ${logType}_planks`)
const crafted = await craftRecipe(
mineflayer,
`${logType}_planks`,
logsToCraft * PLANKS_PER_LOG,
)
if (crafted) {
planksCount = getItemCount(mineflayer, 'planks')
mineflayer.bot.chat(
`I have crafted ${logsToCraft * PLANKS_PER_LOG} ${logType} planks.`,
)
logger.log(`Bot: ${logType} planks crafted.`)
mineflayer.bot.chat(`I have crafted ${logsToCraft * PLANKS_PER_LOG} ${logType} planks.`)
anyCrafted = true
}
else {
// If we have logs but failed to craft planks, it might be due to full inventory or other issues
logger.error(`Bot: Failed to craft ${logType} planks.`)
return false
}
// Check if we have enough planks after crafting
if (planksCount >= neededAmount)
break
}
if (!anyCrafted && availableLogs.length > 0) {
// We had logs but couldn't craft anything? That's a problem.
throw new ActionError('CRAFTING_FAILED', 'Has logs but failed to craft planks', { availableLogs: availableLogs.map(l => l.name) })
}
}
return planksCount >= neededAmount
};
if (planksCount >= neededAmount) {
return true
}
throw new ActionError('RESOURCE_MISSING', 'Failed to ensure enough planks after retries', { needed: neededAmount, current: planksCount })
}
// Helper function to ensure a specific amount of sticks
export async function ensureSticks(mineflayer: Mineflayer, neededAmount: number): Promise<boolean> {
@@ -118,141 +146,113 @@ export async function ensureSticks(mineflayer: Mineflayer, neededAmount: number)
return true
}
while (neededAmount >= sticksCount) {
const maxRetries = 2
let retries = 0
while (neededAmount > sticksCount && retries < maxRetries) {
retries++
const planksCount = getItemCount(mineflayer, 'planks')
const planksNeeded = Math.max(
Math.ceil((neededAmount - sticksCount) / STICKS_PER_PLANK),
4,
2, // Minimum craft is usually 2 planks -> 4 sticks
)
if (planksCount >= planksNeeded) {
try {
const sticksId = getItemId('stick')
const recipe = await mineflayer.bot.recipesFor(sticksId, null, 1, null)[0]
await mineflayer.bot.craft(recipe, neededAmount - sticksCount)
const recipe = mineflayer.bot.recipesFor(sticksId, null, 1, null)[0]
if (!recipe) {
throw new ActionError('CRAFTING_FAILED', 'No recipe for sticks found')
}
await mineflayer.bot.craft(recipe, Math.ceil((neededAmount - sticksCount) / 4)) // Crafting usually gives 4 sticks
sticksCount = getItemCount(mineflayer, 'stick')
mineflayer.bot.chat(`I have made ${Math.abs(neededAmount - sticksCount)} sticks.`)
logger.log(`Bot: Sticks crafted.`)
mineflayer.bot.chat(`I have made sticks.`)
}
catch (err) {
logger.withError(err).error('Bot: Failed to craft sticks.')
return false
throw new ActionError('CRAFTING_FAILED', 'Failed to craft sticks', { error: err })
}
}
else {
await ensurePlanks(mineflayer, planksNeeded)
logger.error('Bot: Not enough planks for sticks.')
}
sticksCount = getItemCount(mineflayer, 'stick')
}
return sticksCount >= neededAmount
if (sticksCount >= neededAmount) return true
throw new ActionError('RESOURCE_MISSING', 'Failed to ensure sticks', { needed: neededAmount, current: sticksCount })
}
// Ensure a specific number of chests
export async function ensureChests(mineflayer: Mineflayer, quantity: number = 1): Promise<boolean> {
logger.log(`Bot: Checking for ${quantity} chest(s)...`)
// Count the number of chests the bot already has
let chestCount = getItemCount(mineflayer, 'chest')
if (chestCount >= quantity) {
logger.log(`Bot: Already has ${quantity} or more chest(s).`)
return true
}
while (chestCount < quantity) {
const planksEnsured = await ensurePlanks(mineflayer, 8 * quantity) // 8 planks per chest
if (!planksEnsured) {
logger.error('Bot: Failed to ensure planks for chest(s).')
continue
}
// Craft the chest(s)
const crafted = await craftRecipe(mineflayer, 'chest', quantity - chestCount)
if (crafted) {
chestCount = getItemCount(mineflayer, 'chest')
mineflayer.bot.chat(`I have crafted ${quantity} chest(s).`)
logger.log(`Bot: ${quantity} chest(s) crafted.`)
continue
}
else {
logger.error('Bot: Failed to craft chest(s).')
}
await ensurePlanks(mineflayer, 8 * (quantity - chestCount))
const crafted = await craftRecipe(mineflayer, 'chest', quantity - chestCount)
if (!crafted) {
throw new ActionError('CRAFTING_FAILED', 'Failed to craft chests')
}
return chestCount >= quantity
return true
}
// Ensure a specific number of furnaces
export async function ensureFurnaces(mineflayer: Mineflayer, quantity: number = 1): Promise<boolean> {
logger.log(`Bot: Checking for ${quantity} furnace(s)...`)
// Count the number of furnaces the bot already has
let furnaceCount = getItemCount(mineflayer, 'furnace')
if (furnaceCount >= quantity) {
logger.log(`Bot: Already has ${quantity} or more furnace(s).`)
return true
}
while (furnaceCount < quantity) {
const stoneEnsured = await ensureCobblestone(mineflayer, 8 * (quantity - furnaceCount)) // 8 stone blocks per furnace
if (!stoneEnsured) {
logger.error('Bot: Failed to ensure stone for furnace(s).')
continue
}
// Craft the furnace(s)
const crafted = await craftRecipe(mineflayer, 'furnace', quantity - furnaceCount)
if (crafted) {
furnaceCount = getItemCount(mineflayer, 'furnace')
mineflayer.bot.chat(`I have crafted ${quantity} furnace(s).`)
logger.log(`Bot: ${quantity} furnace(s) crafted.`)
continue
}
else {
logger.error('Bot: Failed to craft furnace(s).')
}
const stoneNeeded = 8 * (quantity - furnaceCount)
try {
await ensureCobblestone(mineflayer, stoneNeeded)
} catch (e) {
throw new ActionError('RESOURCE_MISSING', 'Failed to gather cobblestone for furnace', { error: e })
}
return furnaceCount >= quantity
const crafted = await craftRecipe(mineflayer, 'furnace', quantity - furnaceCount)
if (!crafted) {
throw new ActionError('CRAFTING_FAILED', 'Failed to craft furnace')
}
return true
}
// Ensure a specific number of torches
export async function ensureTorches(mineflayer: Mineflayer, quantity: number = 1): Promise<boolean> {
logger.log(`Bot: Checking for ${quantity} torch(es)...`)
// Count the number of torches the bot already has
let torchCount = getItemCount(mineflayer, 'torch')
if (torchCount >= quantity) {
logger.log(`Bot: Already has ${quantity} or more torch(es).`)
return true
}
while (torchCount < quantity) {
const sticksEnsured = await ensureSticks(mineflayer, quantity - torchCount) // 1 stick per 4 torches
const coalEnsured = await ensureCoal(
mineflayer,
Math.ceil((quantity - torchCount) / 4),
) // 1 coal per 4 torches
if (!sticksEnsured || !coalEnsured) {
logger.error('Bot: Failed to ensure sticks or coal for torch(es).')
continue
}
// Craft the torch(es)
const crafted = await craftRecipe(mineflayer, 'torch', quantity - torchCount)
if (crafted) {
torchCount = getItemCount(mineflayer, 'torch')
mineflayer.bot.chat(`I have crafted ${quantity} torch(es).`)
logger.log(`Bot: ${quantity} torch(es) crafted.`)
continue
}
else {
logger.error('Bot: Failed to craft torch(es).')
}
const needed = quantity - torchCount
await ensureSticks(mineflayer, Math.ceil(needed / 4))
try {
await ensureCoal(mineflayer, Math.ceil(needed / 4))
} catch (e) {
throw new ActionError('RESOURCE_MISSING', 'Failed to gather coal for torches', { error: e })
}
return torchCount >= quantity
const crafted = await craftRecipe(mineflayer, 'torch', Math.ceil(needed / 4))
if (!crafted) {
throw new ActionError('CRAFTING_FAILED', 'Failed to craft torches')
}
return true
}
// Ensure a campfire
@@ -261,38 +261,32 @@ export async function ensureCampfire(mineflayer: Mineflayer): Promise<boolean> {
logger.log('Bot: Checking for a campfire...')
const hasCampfire = getItemCount(mineflayer, 'campfire') > 0
if (hasCampfire) return true
if (hasCampfire) {
logger.log('Bot: Campfire is already available.')
return true
}
const logsEnsured = await ensurePlanks(mineflayer, 3) // Need 3 logs for a campfire
const sticksEnsured = await ensureSticks(mineflayer, 3) // Need 3 sticks for a campfire
const coalEnsured = await ensureCoal(mineflayer, 1) // Need 1 coal or charcoal for a campfire
if (!logsEnsured || !sticksEnsured || !coalEnsured) {
logger.error('Bot: Failed to ensure resources for campfire.')
await ensurePlanks(mineflayer, 3)
await ensureSticks(mineflayer, 3)
try {
await ensureCoal(mineflayer, 1)
} catch (e) {
throw new ActionError('RESOURCE_MISSING', 'Failed to gather coal/charcoal for campfire', { error: e })
}
const crafted = await craftRecipe(mineflayer, 'campfire', 1)
if (crafted) {
mineflayer.bot.chat('I have crafted a campfire.')
logger.log('Bot: Campfire crafted.')
return true
}
else {
logger.error('Bot: Failed to craft campfire.')
if (!crafted) {
throw new ActionError('CRAFTING_FAILED', 'Failed to craft campfire')
}
return hasCampfire
return true
}
// Helper function to gather cobblestone
export async function ensureCobblestone(mineflayer: Mineflayer, requiredCobblestone: number, maxDistance: number = 4): Promise<boolean> {
let cobblestoneCount = getItemCount(mineflayer, 'cobblestone')
let retries = 0
const maxRetries = 3
while (cobblestoneCount < requiredCobblestone) {
while (cobblestoneCount < requiredCobblestone && retries < maxRetries) {
retries++
logger.log('Bot: Gathering more cobblestone...')
const cobblestoneShortage = requiredCobblestone - cobblestoneCount
@@ -304,57 +298,57 @@ export async function ensureCobblestone(mineflayer: Mineflayer, requiredCobblest
maxDistance,
)
if (!success) {
await moveAway(mineflayer, 30)
await moveAway(mineflayer, 10)
continue
}
}
catch (err) {
catch (err: unknown) {
if (err instanceof Error && err.message.includes('right tools')) {
await ensurePickaxe(mineflayer)
continue
}
else {
logger.withError(err).error('Error collecting cobblestone')
await moveAway(mineflayer, 30)
continue
throw new ActionError('RESOURCE_MISSING', 'Error collecting cobblestone', { error: err })
}
}
cobblestoneCount = getItemCount(mineflayer, 'cobblestone')
}
logger.log('Bot: Collected enough cobblestone.')
return true
if (cobblestoneCount >= requiredCobblestone) return true
throw new ActionError('RESOURCE_MISSING', 'Could not gather enough cobblestone', { required: requiredCobblestone, current: cobblestoneCount })
}
export async function ensureCoal(mineflayer: Mineflayer, neededAmount: number, maxDistance: number = 4): Promise<boolean> {
logger.log('Bot: Checking for coal...')
let coalCount = getItemCount(mineflayer, 'coal')
let retries = 0
const maxRetries = 3
while (coalCount < neededAmount) {
while (coalCount < neededAmount && retries < maxRetries) {
retries++
logger.log('Bot: Gathering more coal...')
const coalShortage = neededAmount - coalCount
try {
await collectBlock(mineflayer, 'stone', coalShortage, maxDistance)
await collectBlock(mineflayer, 'coal_ore', coalShortage, maxDistance)
}
catch (err) {
catch (err: unknown) {
if (err instanceof Error && err.message.includes('right tools')) {
await ensurePickaxe(mineflayer)
continue
}
else {
logger.withError(err).error('Error collecting cobblestone:')
moveAway(mineflayer, 30)
continue
throw new ActionError('RESOURCE_MISSING', 'Error collecting coal', { error: err })
}
}
coalCount = getItemCount(mineflayer, 'cobblestone')
coalCount = getItemCount(mineflayer, 'coal')
}
logger.log('Bot: Collected enough cobblestone.')
return true
if (coalCount >= neededAmount) return true
throw new ActionError('RESOURCE_MISSING', 'Could not gather enough coal', { required: neededAmount, current: coalCount })
}
// Define the valid tool types as a union type
@@ -391,71 +385,62 @@ export function materialsForTool(tool: ToolType): number {
async function ensureTool(mineflayer: Mineflayer, toolType: ToolType, quantity: number = 1): Promise<boolean> {
logger.log(`Bot: Checking for ${quantity} ${toolType}(s)...`)
const neededMaterials = materialsForTool(toolType)
// Check how many of the tool the bot currently has
let toolCount = mineflayer.bot.inventory
.items()
.filter(item => item.name.includes(toolType))
.length
if (toolCount >= quantity) {
logger.log(`Bot: Already has ${quantity} or more ${toolType}(s).`)
return true
}
while (toolCount < quantity) {
// Iterate over the tool materials from best (diamond) to worst (wooden)
for (const material of TOOLS_MATERIALS) {
const toolRecipe = `${material}_${toolType}` // Craft tool name like diamond_pickaxe, iron_sword
const hasResources = await hasResourcesForTool(mineflayer, material, neededMaterials)
// Iterate over the tool materials from best (diamond) to worst (wooden)
for (const material of TOOLS_MATERIALS) {
const toolRecipe = `${material}_${toolType}`
const neededMaterials = materialsForTool(toolType)
const hasResources = await hasResourcesForTool(mineflayer, material, neededMaterials)
// Check if we have enough material for the current tool
if (hasResources) {
if (hasResources) {
try {
await ensureCraftingTable(mineflayer)
const sticksEnsured = await ensureSticks(mineflayer, 2)
if (!sticksEnsured) {
logger.error(
`Bot: Failed to ensure planks or sticks for wooden ${toolType}.`,
)
continue
}
// Craft the tool
await ensureSticks(mineflayer, 2)
const crafted = await craftRecipe(mineflayer, toolRecipe, 1)
if (crafted) {
toolCount++
mineflayer.bot.chat(
`I have crafted a ${material} ${toolType}. Total ${toolType}(s): ${toolCount}/${quantity}`,
)
logger.log(
`Bot: ${material} ${toolType} crafted. Total ${toolCount}/${quantity}`,
)
if (toolCount >= quantity)
return true
}
else {
logger.error(`Bot: Failed to craft ${material} ${toolType}.`)
mineflayer.bot.chat(`I have crafted a ${material} ${toolType}.`)
if (toolCount >= quantity) return true
}
} catch (err) {
if (err instanceof ActionError && err.code === 'RESOURCE_MISSING') {
// Just fall through to next material if resources missing
} else {
logger.error(`Failed to craft ${material} ${toolType}, trying next material.`)
}
}
else if (material === 'wooden') {
// Crafting planks if we don't have enough resources for wooden tools
logger.log(`Bot: Crafting planks for ${material} ${toolType}...`)
await ensurePlanks(mineflayer, 4)
} else if (material === 'wooden') {
// Last resort: make wooden tools
// This will try to gather wood if needed, or throw if it fails
try {
await ensurePlanks(mineflayer, 4)
await ensureCraftingTable(mineflayer)
await ensureSticks(mineflayer, 2)
const crafted = await craftRecipe(mineflayer, `wooden_${toolType}`, 1)
if (crafted) return true
} catch (err) {
throw new ActionError('CRAFTING_FAILED', `Could not craft any ${toolType}`, { error: err })
}
}
}
return toolCount >= quantity
throw new ActionError('CRAFTING_FAILED', `Failed to ensure ${toolType} of any material`)
}
// Helper function to check if the bot has enough materials to craft a tool of a specific material
export async function hasResourcesForTool(
mineflayer: Mineflayer,
material: MaterialType,
num = 3, // Number of resources needed for most tools
num = 3,
): Promise<boolean> {
switch (material) {
case 'diamond':
+74 -98
View File
@@ -4,6 +4,7 @@ import type { Recipe } from 'prismarine-recipe'
import type { Mineflayer } from '../libs/mineflayer'
import { ActionError } from '../utils/errors'
import { useLogger } from '../utils/logger'
import { getItemId, getItemName } from '../utils/mcdata'
import { ensureCraftingTable } from './actions/ensure'
@@ -13,31 +14,6 @@ import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './worl
const logger = useLogger()
/*
Possible Scenarios:
1. **Successful Craft Without Crafting Table**:
- The bot attempts to craft the item without a crafting table and succeeds. The function returns `true`.
2. **Crafting Table Nearby**:
- The bot tries to craft without a crafting table but fails.
- The bot then checks for a nearby crafting table.
- If a crafting table is found, the bot moves to it and successfully crafts the item, returning `true`.
3. **No Crafting Table Nearby, Place Crafting Table**:
- The bot fails to craft without a crafting table and does not find a nearby crafting table.
- The bot checks inventory for a crafting table, places it at a suitable location, and attempts crafting again.
- If successful, the function returns `true`. If the bot cannot find a suitable position or fails to craft, it returns `false`.
4. **Insufficient Resources**:
- At any point, if the bot does not have the required resources to craft the item, it logs an appropriate message and returns `false`.
5. **No Crafting Table and No Suitable Position**:
- If the bot does not find a crafting table and cannot find a suitable position to place one, it moves away and returns `false`.
6. **Invalid Item Name**:
- If the provided item name is invalid, the function logs the error and returns `false`.
*/
export async function craftRecipe(
mineflayer: Mineflayer,
incomingItemName: string,
@@ -50,8 +26,7 @@ export async function craftRecipe(
const itemId = getItemId(itemName)
if (itemId === null) {
logger.log(`Invalid item name: ${itemName}`)
return false
throw new ActionError('UNKNOWN', `Invalid item name: ${itemName}`)
}
// Helper function to attempt crafting
@@ -71,8 +46,7 @@ export async function craftRecipe(
return true
}
catch (err) {
logger.log(`Failed to craft ${itemName}: ${(err as Error).message}`)
return false
throw new ActionError('CRAFTING_FAILED', `Failed to craft ${itemName}`, { error: err })
}
}
return false
@@ -95,6 +69,10 @@ export async function craftRecipe(
1,
)
const recipes = mineflayer.bot.recipesFor(itemId, null, 1, craftingTable)
if (!recipes || recipes.length === 0) {
// If we have a crafting table but still no recipes, we are missing materials
return false // Let the caller decide or fall through
}
success = await attemptCraft(recipes, craftingTable)
}
catch (err) {
@@ -103,10 +81,15 @@ export async function craftRecipe(
(err as Error).message
}`,
)
if (err instanceof ActionError) throw err
}
attempts++
}
if (!success) {
throw new ActionError('NAVIGATION_FAILED', 'Could not reach crafting table')
}
return success
}
@@ -120,11 +103,8 @@ export async function craftRecipe(
}
logger.log(`No crafting table nearby, attempting to place one.`)
const hasCraftingTable = await ensureCraftingTable(mineflayer)
if (!hasCraftingTable) {
logger.log(`Failed to ensure a crafting table to craft ${itemName}.`)
return false
}
// valid: ensureCraftingTable now throws ActionError if it fails
await ensureCraftingTable(mineflayer)
const pos = getNearestFreeSpace(mineflayer, 1, 10)
if (pos) {
@@ -139,9 +119,7 @@ export async function craftRecipe(
}
}
else {
logger.log('No suitable position found to place the crafting table.')
moveAway(mineflayer, 5)
return false
throw new ActionError('CRAFTING_FAILED', 'No suitable position found to place the crafting table')
}
return false
@@ -150,18 +128,35 @@ export async function craftRecipe(
// Step 1: Try to craft without a crafting table
logger.log(`Step 1: Try to craft without a crafting table`)
const recipes = mineflayer.bot.recipesFor(itemId, null, 1, null)
if (recipes && (await attemptCraft(recipes))) {
return true
if (recipes && recipes.length > 0) {
// We have recipes without table
if (await attemptCraft(recipes)) {
return true
}
}
// RECURSION GUARD:
// If we failed to craft basic items (planks, sticks) without a table,
// do NOT try to find a table. These items do not need a table.
// Seeking a table often triggers "ensureCraftingTable" -> "ensurePlanks" -> infinite loop.
if (itemName.includes('planks') || itemName === 'stick' || itemName === 'crafting_table') {
logger.log(`Recursion Guard: Skipping crafting table search for basic item: ${itemName}`)
throw new ActionError('RESOURCE_MISSING', `Cannot craft ${itemName} - missing ingredients`, { item: itemName })
}
// Step 2: Find and use a crafting table
// This will throw if it fails hard
logger.log(`Step 2: Find and use a crafting table`)
const craftingTableRange = 32
if (await findAndUseCraftingTable(craftingTableRange)) {
return true
}
return false
// If we got here, maybe we didn't have recipes even with a table?
// Let's verify if resources are missing
// We can check recipes again assuming table is available (which we tried to ensure)
// Simple fallback:
throw new ActionError('RESOURCE_MISSING', `Cannot craft ${itemName}, possibly missing resources`, { item: itemName })
}
export async function smeltItem(mineflayer: Mineflayer, itemName: string, num = 1): Promise<boolean> {
@@ -176,11 +171,8 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
'tropical_fish',
]
if (!itemName.includes('raw') && !foods.includes(itemName)) {
logger.log(
`Cannot smelt ${itemName}, must be a "raw" item, like "raw_iron".`,
)
return false
} // TODO: allow cobblestone, sand, clay, etc.
throw new ActionError('CRAFTING_FAILED', `Cannot smelt ${itemName}, must be a "raw" item`)
}
let placedFurnace = false
let furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32)
@@ -193,17 +185,16 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
await placeBlock(mineflayer, 'furnace', pos.x, pos.y, pos.z)
}
else {
logger.log('No suitable position found to place the furnace.')
return false
throw new ActionError('CRAFTING_FAILED', 'No suitable position found to place the furnace')
}
furnaceBlock = getNearestBlock(mineflayer, 'furnace', 32)
placedFurnace = true
}
}
if (!furnaceBlock) {
logger.log(`There is no furnace nearby and I have no furnace.`)
return false
throw new ActionError('RESOURCE_MISSING', 'There is no furnace nearby and I have no furnace to place')
}
if (mineflayer.bot.entity.position.distanceTo(furnaceBlock.position) > 4) {
await goToNearestBlock(mineflayer, 'furnace', 4, 32)
}
@@ -218,22 +209,15 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
&& inputItem.type !== getItemId(itemName)
&& inputItem.count > 0
) {
logger.log(
`The furnace is currently smelting ${getItemName(
inputItem.type,
)}.`,
)
if (placedFurnace)
await collectBlock(mineflayer, 'furnace', 1)
return false
if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1)
throw new ActionError('CRAFTING_FAILED', `The furnace is currently smelting ${getItemName(inputItem.type)}`)
}
// Check if the bot has enough items to smelt
const invCounts = getInventoryCounts(mineflayer)
if (!invCounts[itemName] || invCounts[itemName] < num) {
logger.log(`I do not have enough ${itemName} to smelt.`)
if (placedFurnace)
await collectBlock(mineflayer, 'furnace', 1)
return false
if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1)
throw new ActionError('RESOURCE_MISSING', `I do not have enough ${itemName} to smelt`, { required: num })
}
// Fuel the furnace
@@ -243,32 +227,38 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
.find(item => item.name === 'coal' || item.name === 'charcoal')
const putFuel = Math.ceil(num / 8)
if (!fuel || fuel.count < putFuel) {
logger.log(
`I do not have enough coal or charcoal to smelt ${num} ${itemName}, I need ${putFuel} coal or charcoal`,
)
if (placedFurnace)
await collectBlock(mineflayer, 'furnace', 1)
return false
if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1)
throw new ActionError('RESOURCE_MISSING', `I do not have enough coal or charcoal to smelt`, { required: putFuel })
}
await furnace.putFuel(fuel.type, null, putFuel)
logger.log(
`Added ${putFuel} ${getItemName(fuel.type)} to furnace fuel.`,
)
}
// Put the items in the furnace
const itemId = getItemId(itemName)
if (itemId === null) {
logger.log(`Invalid item name: ${itemName}`)
return false
if (placedFurnace) await collectBlock(mineflayer, 'furnace', 1)
throw new ActionError('UNKNOWN', `Invalid item name: ${itemName}`)
}
await furnace.putInput(itemId, null, num)
// Wait for the items to smelt
let total = 0
let collectedLast = true
let smeltedItem: Item | null = null
await new Promise(resolve => setTimeout(resolve, 200))
// Wait limit 30s per item?
const maxWait = num * 12000 // approx 10s per item + buffer
let waited = 0
while (total < num) {
await new Promise(resolve => setTimeout(resolve, 10000))
await new Promise(resolve => setTimeout(resolve, 5000))
waited += 5000
// Safety break
if (waited > maxWait) {
break
}
logger.log('checking...')
let collected = false
if (furnace.outputItem()) {
@@ -279,7 +269,11 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
}
}
if (!collected && !collectedLast) {
break // if nothing was collected this time or last time
// If we didn't collect anything twice in a row, maybe it stopped?
// Check input
if (!furnace.inputItem() && !furnace.outputItem()) {
break // empty?
}
}
collectedLast = collected
}
@@ -288,16 +282,11 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
if (placedFurnace) {
await collectBlock(mineflayer, 'furnace', 1)
}
if (total === 0) {
logger.log(`Failed to smelt ${itemName}.`)
return false
}
if (total < num) {
logger.log(
`Only smelted ${total} ${getItemName(smeltedItem?.type || 0)}.`,
)
return false
throw new ActionError('CRAFTING_FAILED', `Failed to smelt all items, only got ${total}/${num}`)
}
logger.log(
`Successfully smelted ${itemName}, got ${total} ${getItemName(
smeltedItem?.type || 0,
@@ -309,8 +298,7 @@ export async function smeltItem(mineflayer: Mineflayer, itemName: string, num =
export async function clearNearestFurnace(mineflayer: Mineflayer): Promise<boolean> {
const furnaceBlock = getNearestBlock(mineflayer, 'furnace', 6)
if (!furnaceBlock) {
logger.log(`There is no furnace nearby.`)
return false
throw new ActionError('NAVIGATION_FAILED', 'No furnace nearby to clear')
}
logger.log('clearing furnace...')
@@ -326,19 +314,7 @@ export async function clearNearestFurnace(mineflayer: Mineflayer): Promise<boole
inputItem = await furnace.takeInput()
if (furnace.fuelItem())
fuelItem = await furnace.takeFuel()
logger.log(smeltedItem, inputItem, fuelItem)
const smeltedName = smeltedItem
? `${smeltedItem.count} ${smeltedItem.name}`
: `0 smelted items`
const inputName = inputItem
? `${inputItem.count} ${inputItem.name}`
: `0 input items`
const fuelName = fuelItem
? `${fuelItem.count} ${fuelItem.name}`
: `0 fuel items`
logger.log(
`Cleared furnace, received ${smeltedName}, ${inputName}, and ${fuelName}.`,
)
await mineflayer.bot.closeWindow(furnace)
return true
}
+27
View File
@@ -0,0 +1,27 @@
export type ActionErrorCode =
| 'RESOURCE_MISSING'
| 'CRAFTING_FAILED'
| 'NAVIGATION_FAILED'
| 'INTERRUPTED'
| 'INVENTORY_FULL'
| 'UNKNOWN'
export class ActionError extends Error {
public readonly code: ActionErrorCode
public readonly context?: Record<string, unknown>
constructor(code: ActionErrorCode, message: string, context?: Record<string, unknown>) {
super(message)
this.name = 'ActionError'
this.code = code
this.context = context
}
public toJSON() {
return {
message: this.message,
code: this.code,
context: this.context,
}
}
}