feat(minecraft): autocrafter and recepie planner
This commit is contained in:
@@ -9,6 +9,7 @@ import { discard, equip, putInChest, takeFromChest } from '../../skills/actions/
|
||||
import { activateNearestBlock, breakBlockAt, placeBlock } from '../../skills/actions/world-interactions'
|
||||
import { ActionError } from '../../utils/errors'
|
||||
import { useLogger } from '../../utils/logger'
|
||||
import { describeRecipePlan, planRecipe } from '../../utils/recipe-planner'
|
||||
|
||||
import * as skills from '../../skills'
|
||||
import * as world from '../../skills/world'
|
||||
@@ -427,4 +428,51 @@ export const actionsList: Action[] = [
|
||||
return `Activated nearest [${type}]`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'recipePlan',
|
||||
description: 'Plan how to craft an item. Shows the full recipe tree, what resources you have, what you\'re missing, and whether you can craft it now. Use this BEFORE attempting to craft complex items to understand what you need.',
|
||||
execution: 'parallel',
|
||||
schema: z.object({
|
||||
item_name: z.string().describe('The name of the item you want to craft (e.g., "diamond_pickaxe", "oak_planks").'),
|
||||
amount: z.number().int().min(1).default(1).describe('How many of the item you want to craft.'),
|
||||
}),
|
||||
perform: mineflayer => (item_name: string, amount: number = 1): string => {
|
||||
return pad(describeRecipePlan(mineflayer.bot, item_name, amount))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'autoCraft',
|
||||
description: 'Automatically craft an item if you have all the required resources. This will check the recipe, verify you have materials, and craft it. Use recipePlan first to see if crafting is possible.',
|
||||
execution: 'sequential',
|
||||
schema: z.object({
|
||||
item_name: z.string().describe('The name of the item to craft.'),
|
||||
amount: z.number().int().min(1).default(1).describe('How many of the item to craft.'),
|
||||
}),
|
||||
perform: mineflayer => async (item_name: string, amount: number = 1) => {
|
||||
const plan = planRecipe(mineflayer.bot, item_name, amount)
|
||||
|
||||
if (plan.status === 'unknown_item') {
|
||||
throw new ActionError('UNKNOWN', `Unknown item: ${item_name}`)
|
||||
}
|
||||
|
||||
if (!plan.canCraftNow) {
|
||||
const missingList = Object.entries(plan.missing)
|
||||
.map(([item, count]) => `${count}x ${item}`)
|
||||
.join(', ')
|
||||
throw new ActionError('RESOURCE_MISSING', `Cannot craft ${item_name}: missing ${missingList}`, {
|
||||
missing: plan.missing,
|
||||
required: plan.totalRequired,
|
||||
})
|
||||
}
|
||||
|
||||
// Craft all intermediate steps first, then the final item
|
||||
for (const step of [...plan.steps].reverse()) {
|
||||
if (step.action === 'craft') {
|
||||
await skills.craftRecipe(mineflayer, step.item, Math.ceil(step.amount / (step.amount || 1)))
|
||||
}
|
||||
}
|
||||
|
||||
return `Successfully crafted ${amount}x ${item_name}`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import type { Bot } from 'mineflayer'
|
||||
|
||||
import { getItemAnimalSource, getItemSmeltingIngredient, McData } from './mcdata'
|
||||
|
||||
export type RecipeStatus
|
||||
= | 'craftable' // Can craft right now with inventory
|
||||
| 'missing_resources' // Craftable but missing some ingredients
|
||||
| 'requires_smelting' // Needs furnace (can't auto-craft)
|
||||
| 'requires_gathering' // Must be mined/gathered (not craftable)
|
||||
| 'unknown_item' // Item doesn't exist
|
||||
|
||||
export interface RecipeStep {
|
||||
action: 'craft' | 'smelt' | 'gather' | 'hunt'
|
||||
item: string
|
||||
amount: number
|
||||
ingredients?: Record<string, number>
|
||||
requiresCraftingTable?: boolean
|
||||
source?: string // For gather/hunt: where to get it
|
||||
}
|
||||
|
||||
export interface RecipePlan {
|
||||
targetItem: string
|
||||
targetAmount: number
|
||||
status: RecipeStatus
|
||||
steps: RecipeStep[]
|
||||
totalRequired: Record<string, number> // All base resources needed
|
||||
available: Record<string, number> // What we have
|
||||
missing: Record<string, number> // What we're missing
|
||||
canCraftNow: boolean // True if we can craft immediately
|
||||
requiresCraftingTable: boolean
|
||||
}
|
||||
|
||||
export class RecipePlanner {
|
||||
private mcData: McData
|
||||
private bot: Bot
|
||||
private inventory: Record<string, number>
|
||||
|
||||
constructor(bot: Bot) {
|
||||
this.bot = bot
|
||||
this.mcData = McData.fromBot(bot)
|
||||
this.inventory = this.getInventoryCounts()
|
||||
}
|
||||
|
||||
private getInventoryCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const item of this.bot.inventory.items()) {
|
||||
counts[item.name] = (counts[item.name] || 0) + item.count
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
private getRecipeOutput(itemName: string): number {
|
||||
const itemId = this.mcData.getItemId(itemName)
|
||||
if (!itemId)
|
||||
return 1
|
||||
const recipes = this.mcData.registry.recipes[itemId]
|
||||
if (!recipes || recipes.length === 0)
|
||||
return 1
|
||||
// Get the result count from the first recipe
|
||||
const recipe = recipes[0] as any
|
||||
return recipe.result?.count ?? 1
|
||||
}
|
||||
|
||||
private doesRecipeRequireCraftingTable(itemName: string): boolean {
|
||||
const itemId = this.mcData.getItemId(itemName)
|
||||
if (!itemId)
|
||||
return false
|
||||
const recipes = this.bot.recipesFor(itemId, null, 1, null)
|
||||
// If no recipes without table, it requires a table
|
||||
return recipes.length === 0
|
||||
}
|
||||
|
||||
private selectBestRecipe(recipes: Record<string, number>[]): Record<string, number> | null {
|
||||
if (recipes.length === 0)
|
||||
return null
|
||||
if (recipes.length === 1)
|
||||
return recipes[0]
|
||||
|
||||
// Heuristic: pick the recipe where we have the most ingredients available
|
||||
let bestRecipe = recipes[0]
|
||||
let bestScore = -1
|
||||
|
||||
for (const recipe of recipes) {
|
||||
let score = 0
|
||||
for (const [ingredient, needed] of Object.entries(recipe)) {
|
||||
const have = this.inventory[ingredient] || 0
|
||||
score += Math.min(have, needed)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestRecipe = recipe
|
||||
}
|
||||
}
|
||||
|
||||
return bestRecipe
|
||||
}
|
||||
|
||||
private getGatherSource(itemName: string): string | null {
|
||||
// Check block sources
|
||||
const blockSources = this.mcData.getItemBlockSources(itemName)
|
||||
if (blockSources.length > 0) {
|
||||
return `mine ${blockSources[0]}`
|
||||
}
|
||||
|
||||
// Check animal sources
|
||||
const animalSource = getItemAnimalSource(itemName)
|
||||
if (animalSource) {
|
||||
return `hunt ${animalSource}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
plan(itemName: string, amount: number): RecipePlan {
|
||||
const plan: RecipePlan = {
|
||||
targetItem: itemName,
|
||||
targetAmount: amount,
|
||||
status: 'unknown_item',
|
||||
steps: [],
|
||||
totalRequired: {},
|
||||
available: { ...this.inventory },
|
||||
missing: {},
|
||||
canCraftNow: false,
|
||||
requiresCraftingTable: false,
|
||||
}
|
||||
|
||||
// Validate item exists
|
||||
const itemId = this.mcData.getItemId(itemName)
|
||||
if (!itemId) {
|
||||
plan.status = 'unknown_item'
|
||||
return plan
|
||||
}
|
||||
|
||||
// Build the dependency tree
|
||||
const visited = new Set<string>()
|
||||
this.buildDependencyTree(itemName, amount, plan, visited)
|
||||
|
||||
// Determine final status
|
||||
if (Object.keys(plan.missing).length === 0) {
|
||||
plan.status = 'craftable'
|
||||
plan.canCraftNow = true
|
||||
}
|
||||
else {
|
||||
// Check if any missing items require smelting or gathering
|
||||
const hasSmeltingStep = plan.steps.some(s => s.action === 'smelt')
|
||||
const hasGatherStep = plan.steps.some(s => s.action === 'gather' || s.action === 'hunt')
|
||||
|
||||
if (hasSmeltingStep) {
|
||||
plan.status = 'requires_smelting'
|
||||
}
|
||||
else if (hasGatherStep) {
|
||||
plan.status = 'requires_gathering'
|
||||
}
|
||||
else {
|
||||
plan.status = 'missing_resources'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if crafting table is needed
|
||||
plan.requiresCraftingTable = plan.steps.some(s => s.action === 'craft' && s.requiresCraftingTable)
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
private buildDependencyTree(
|
||||
itemName: string,
|
||||
amount: number,
|
||||
plan: RecipePlan,
|
||||
visited: Set<string>,
|
||||
): void {
|
||||
// Prevent infinite recursion
|
||||
if (visited.has(itemName)) {
|
||||
return
|
||||
}
|
||||
visited.add(itemName)
|
||||
|
||||
// Check if we already have enough
|
||||
const have = plan.available[itemName] || 0
|
||||
if (have >= amount) {
|
||||
plan.available[itemName] = have - amount
|
||||
return
|
||||
}
|
||||
|
||||
const needed = amount - have
|
||||
if (have > 0) {
|
||||
plan.available[itemName] = 0
|
||||
}
|
||||
|
||||
// Check for smelting recipe first
|
||||
const smeltIngredient = getItemSmeltingIngredient(itemName)
|
||||
if (smeltIngredient) {
|
||||
plan.steps.push({
|
||||
action: 'smelt',
|
||||
item: itemName,
|
||||
amount: needed,
|
||||
ingredients: { [smeltIngredient]: needed },
|
||||
source: `smelt ${smeltIngredient} in furnace`,
|
||||
})
|
||||
plan.totalRequired[smeltIngredient] = (plan.totalRequired[smeltIngredient] || 0) + needed
|
||||
|
||||
// Recurse for the smelting ingredient
|
||||
this.buildDependencyTree(smeltIngredient, needed, plan, visited)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for crafting recipes
|
||||
const recipes = this.mcData.getItemCraftingRecipes(itemName)
|
||||
if (recipes && recipes.length > 0) {
|
||||
const recipe = this.selectBestRecipe(recipes)
|
||||
if (recipe) {
|
||||
const outputPerCraft = this.getRecipeOutput(itemName)
|
||||
const craftTimes = Math.ceil(needed / outputPerCraft)
|
||||
const requiresTable = this.doesRecipeRequireCraftingTable(itemName)
|
||||
|
||||
// Scale ingredients by craft times
|
||||
const scaledIngredients: Record<string, number> = {}
|
||||
for (const [ingredient, perCraft] of Object.entries(recipe)) {
|
||||
scaledIngredients[ingredient] = perCraft * craftTimes
|
||||
}
|
||||
|
||||
plan.steps.push({
|
||||
action: 'craft',
|
||||
item: itemName,
|
||||
amount: needed,
|
||||
ingredients: scaledIngredients,
|
||||
requiresCraftingTable: requiresTable,
|
||||
})
|
||||
|
||||
// Add to total required and recurse
|
||||
for (const [ingredient, ingredientAmount] of Object.entries(scaledIngredients)) {
|
||||
plan.totalRequired[ingredient] = (plan.totalRequired[ingredient] || 0) + ingredientAmount
|
||||
this.buildDependencyTree(ingredient, ingredientAmount, plan, visited)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Not craftable - must be gathered
|
||||
const gatherSource = this.getGatherSource(itemName)
|
||||
const animalSource = getItemAnimalSource(itemName)
|
||||
|
||||
plan.steps.push({
|
||||
action: animalSource ? 'hunt' : 'gather',
|
||||
item: itemName,
|
||||
amount: needed,
|
||||
source: gatherSource || 'unknown source',
|
||||
})
|
||||
|
||||
plan.totalRequired[itemName] = (plan.totalRequired[itemName] || 0) + needed
|
||||
plan.missing[itemName] = (plan.missing[itemName] || 0) + needed
|
||||
}
|
||||
|
||||
describe(plan: RecipePlan): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`=== Recipe Plan: ${plan.targetAmount}x ${plan.targetItem} ===`)
|
||||
lines.push(`Status: ${this.formatStatus(plan.status)}`)
|
||||
lines.push('')
|
||||
|
||||
if (plan.status === 'unknown_item') {
|
||||
lines.push(`The item "${plan.targetItem}" does not exist.`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Show steps in reverse order (base materials first)
|
||||
const reversedSteps = [...plan.steps].reverse()
|
||||
|
||||
if (reversedSteps.length > 0) {
|
||||
lines.push('Steps:')
|
||||
for (let i = 0; i < reversedSteps.length; i++) {
|
||||
const step = reversedSteps[i]
|
||||
lines.push(`${i + 1}. ${this.formatStep(step)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Show what we have vs what we need
|
||||
if (Object.keys(plan.totalRequired).length > 0) {
|
||||
lines.push('Resources needed:')
|
||||
for (const [item, needed] of Object.entries(plan.totalRequired)) {
|
||||
const have = this.inventory[item] || 0
|
||||
const missing = plan.missing[item] || 0
|
||||
const status = missing > 0 ? `❌ missing ${missing}` : '✓ have enough'
|
||||
lines.push(`- ${item}: need ${needed}, have ${have} (${status})`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (plan.canCraftNow) {
|
||||
const tableNote = plan.requiresCraftingTable ? ' (requires crafting table)' : ''
|
||||
lines.push(`✓ You can craft this now${tableNote}!`)
|
||||
}
|
||||
else if (Object.keys(plan.missing).length > 0) {
|
||||
lines.push('Missing resources:')
|
||||
for (const [item, amount] of Object.entries(plan.missing)) {
|
||||
const source = this.getGatherSource(item)
|
||||
lines.push(`- ${amount}x ${item}${source ? ` (${source})` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private formatStatus(status: RecipeStatus): string {
|
||||
switch (status) {
|
||||
case 'craftable':
|
||||
return 'CRAFTABLE - Ready to craft!'
|
||||
case 'missing_resources':
|
||||
return 'MISSING RESOURCES - Need to gather more materials'
|
||||
case 'requires_smelting':
|
||||
return 'REQUIRES SMELTING - Need to use a furnace'
|
||||
case 'requires_gathering':
|
||||
return 'REQUIRES GATHERING - Need to mine/hunt for materials'
|
||||
case 'unknown_item':
|
||||
return 'UNKNOWN ITEM - Item does not exist'
|
||||
}
|
||||
}
|
||||
|
||||
private formatStep(step: RecipeStep): string {
|
||||
switch (step.action) {
|
||||
case 'craft': {
|
||||
const ingredients = Object.entries(step.ingredients || {})
|
||||
.map(([item, count]) => `${count}x ${item}`)
|
||||
.join(' + ')
|
||||
const tableNote = step.requiresCraftingTable ? ' [needs crafting table]' : ''
|
||||
return `Craft ${step.amount}x ${step.item} from ${ingredients}${tableNote}`
|
||||
}
|
||||
case 'smelt':
|
||||
return `Smelt ${step.amount}x ${step.item} (${step.source})`
|
||||
case 'gather':
|
||||
return `Gather ${step.amount}x ${step.item} (${step.source})`
|
||||
case 'hunt':
|
||||
return `Hunt for ${step.amount}x ${step.item} (${step.source})`
|
||||
}
|
||||
}
|
||||
|
||||
canCraftImmediately(): { canCraft: boolean, hasCraftingTable: boolean } {
|
||||
const craftingTable = this.bot.findBlock({
|
||||
matching: block => block.name === 'crafting_table',
|
||||
maxDistance: 4,
|
||||
})
|
||||
return {
|
||||
canCraft: true,
|
||||
hasCraftingTable: craftingTable !== null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function planRecipe(bot: Bot, itemName: string, amount: number): RecipePlan {
|
||||
const planner = new RecipePlanner(bot)
|
||||
return planner.plan(itemName, amount)
|
||||
}
|
||||
|
||||
export function describeRecipePlan(bot: Bot, itemName: string, amount: number): string {
|
||||
const planner = new RecipePlanner(bot)
|
||||
const plan = planner.plan(itemName, amount)
|
||||
return planner.describe(plan)
|
||||
}
|
||||
Reference in New Issue
Block a user