Files
moeka-project/services/minecraft/src/utils/recipe-planner.ts
T

360 lines
11 KiB
TypeScript

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)
}