feat(minecraft): autocrafter and recepie planner

This commit is contained in:
Rin
2026-02-18 11:12:46 +08:00
committed by Neko Ayaka
parent 377d9378bd
commit b4f606e538
2 changed files with 407 additions and 0 deletions
@@ -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)
}