Last active
January 29, 2026 18:03
-
-
Save requizm/5827851337b22bd6b5b725847bb4eb01 to your computer and use it in GitHub Desktop.
Azalea example to show how to load recipes from minecraft assets and crafting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use crate::bot::utils::matches_tag; | |
| use azalea::registry::builtin::ItemKind; | |
| use serde::Deserialize; | |
| use std::collections::HashMap; | |
| use std::fs; | |
| use std::path::Path; | |
| #[derive(Debug, Clone)] | |
| pub enum Ingredient { | |
| Item(ItemKind), | |
| Tag(String), | |
| List(Vec<Ingredient>), | |
| } | |
| impl Ingredient { | |
| /// Check if a given item matches this ingredient. | |
| pub fn matches(&self, item: ItemKind) -> bool { | |
| match self { | |
| Ingredient::Item(k) => *k == item, | |
| Ingredient::Tag(t) => { | |
| let item_name = serde_json::to_string(&item) | |
| .unwrap_or_else(|_| format!("{:?}", item)) | |
| .trim_matches('"') | |
| .replace("minecraft:", "") | |
| .to_lowercase(); | |
| matches_tag(&item_name, t) | |
| } | |
| Ingredient::List(opts) => opts.iter().any(|i| i.matches(item)), | |
| } | |
| } | |
| } | |
| impl std::fmt::Display for Ingredient { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| match self { | |
| Ingredient::Item(kind) => { | |
| let s = serde_json::to_string(&kind).unwrap_or_else(|_| format!("{:?}", kind)); | |
| write!(f, "{}", s.trim_matches('"').replace("minecraft:", "")) | |
| } | |
| Ingredient::Tag(tag) => { | |
| write!(f, "{}", tag.replace("minecraft:", "")) | |
| } | |
| Ingredient::List(list) => { | |
| let encoded: Vec<String> = list.iter().map(|i| format!("{}", i)).collect(); | |
| write!(f, "[{}]", encoded.join(" or ")) | |
| } | |
| } | |
| } | |
| } | |
| /// Raw JSON representation of an ingredient | |
| #[derive(Deserialize, Debug, Clone)] | |
| #[serde(untagged)] | |
| pub enum RawIngredient { | |
| Simple(String), | |
| Single { | |
| item: Option<String>, | |
| tag: Option<String>, | |
| }, | |
| List(Vec<RawIngredient>), | |
| } | |
| impl RawIngredient { | |
| pub fn parse(&self) -> Option<Ingredient> { | |
| match self { | |
| RawIngredient::Simple(s) => { | |
| let clean = s.strip_prefix("minecraft:").unwrap_or(s); | |
| let full = format!("minecraft:{}", clean); | |
| // Try to parse as ItemKind | |
| if let Ok(kind) = serde_json::from_value(serde_json::Value::String(full.clone())) { | |
| Some(Ingredient::Item(kind)) | |
| } else { | |
| // Try parsing as a tag if it starts with # | |
| if let Some(tag_name) = s.strip_prefix("#") { | |
| Some(Ingredient::Tag(tag_name.to_string())) | |
| } else { | |
| // println!("Warning: Could not parse simple item kind: {}", full); | |
| None | |
| } | |
| } | |
| } | |
| RawIngredient::Single { item, tag } => { | |
| if let Some(i) = item { | |
| let clean = i.strip_prefix("minecraft:").unwrap_or(i); | |
| let full = format!("minecraft:{}", clean); | |
| // Try to parse as ItemKind | |
| if let Ok(kind) = | |
| serde_json::from_value(serde_json::Value::String(full.clone())) | |
| { | |
| return Some(Ingredient::Item(kind)); | |
| } else { | |
| // If it fails to parse as ItemKind, it might be a custom item or just invalid. | |
| // For safety, warn or return None. | |
| println!("Warning: Could not parse item kind: {}", full); | |
| return None; | |
| } | |
| } | |
| if let Some(t) = tag { | |
| return Some(Ingredient::Tag(t.clone())); | |
| } | |
| None | |
| } | |
| RawIngredient::List(list) => { | |
| let parsed: Vec<_> = list.iter().filter_map(|r| r.parse()).collect(); | |
| if parsed.is_empty() { | |
| None | |
| } else { | |
| Some(Ingredient::List(parsed)) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| #[derive(Deserialize, Debug, Clone)] | |
| pub struct RecipeResult { | |
| pub id: String, | |
| #[serde(default = "default_count")] | |
| pub count: u32, | |
| } | |
| fn default_count() -> u32 { | |
| 1 | |
| } | |
| #[derive(Deserialize, Debug, Clone)] | |
| pub struct RawRecipe { | |
| #[serde(rename = "type")] | |
| pub recipe_type: String, | |
| pub group: Option<String>, | |
| pub category: Option<String>, | |
| // Crafting Shaped | |
| pub pattern: Option<serde_json::Value>, | |
| pub key: Option<HashMap<String, RawIngredient>>, | |
| // Crafting Shapeless | |
| pub ingredients: Option<Vec<RawIngredient>>, | |
| // Transmute / Stonecutting / Smelting | |
| pub input: Option<RawIngredient>, // transmute input | |
| pub ingredient: Option<RawIngredient>, // stonecutting/smelting input | |
| pub material: Option<RawIngredient>, | |
| // Smithing | |
| pub base: Option<RawIngredient>, | |
| pub template: Option<RawIngredient>, | |
| pub addition: Option<RawIngredient>, | |
| // Smelting | |
| pub cookingtime: Option<u32>, | |
| pub experience: Option<f64>, | |
| pub result: Option<RecipeResult>, | |
| } | |
| #[derive(Debug, Clone)] | |
| pub enum RecipeMatch { | |
| Shaped { | |
| width: usize, | |
| height: usize, | |
| pattern: Vec<Vec<Ingredient>>, | |
| }, | |
| Shapeless { | |
| ingredients: Vec<Ingredient>, | |
| }, | |
| } | |
| #[derive(Debug, Clone)] | |
| pub struct Recipe { | |
| pub result: RecipeResult, | |
| // We store the resulted match in a resolved ItemKind if possible, for easier lookup return | |
| pub result_kind: Option<ItemKind>, | |
| pub matcher: RecipeMatch, | |
| } | |
| pub type RecipeBook = HashMap<ItemKind, Vec<Recipe>>; | |
| pub struct RecipeManager { | |
| pub book: RecipeBook, | |
| } | |
| impl RecipeManager { | |
| pub fn new() -> Self { | |
| RecipeManager { | |
| book: load_recipes_from_dir("recipes"), | |
| } | |
| } | |
| pub fn get_recipes_for(&self, item: ItemKind) -> Option<&Vec<Recipe>> { | |
| self.book.get(&item) | |
| } | |
| } | |
| fn load_recipes_from_dir(dir_path: &str) -> RecipeBook { | |
| let mut book = RecipeBook::new(); | |
| let path = Path::new(dir_path); | |
| if !path.exists() || !path.is_dir() { | |
| println!("Warning: Recipe directory '{}' not found.", dir_path); | |
| return book; | |
| } | |
| let entries = fs::read_dir(path).expect("Failed to read recipes directory"); | |
| for entry in entries.flatten() { | |
| let path = entry.path(); | |
| if path.extension().and_then(|s| s.to_str()) == Some("json") { | |
| let json_str = fs::read_to_string(&path).unwrap_or_default(); | |
| // Try parsing | |
| if let Ok(raw) = serde_json::from_str::<RawRecipe>(&json_str) { | |
| // If result is missing (special recipes), skip for now | |
| if let Some(result) = &raw.result { | |
| // Determine result ItemKind | |
| let result_id_clean = | |
| result.id.strip_prefix("minecraft:").unwrap_or(&result.id); | |
| let result_full = format!("minecraft:{}", result_id_clean); | |
| let result_kind = serde_json::from_value::<ItemKind>( | |
| serde_json::Value::String(result_full.clone()), | |
| ) | |
| .ok(); | |
| if let Some(recipe) = process_raw_recipe(raw, result_kind) { | |
| if let Some(rk) = result_kind { | |
| book.entry(rk).or_default().push(recipe); | |
| } | |
| } | |
| } | |
| } else { | |
| if let Err(e) = serde_json::from_str::<RawRecipe>(&json_str) { | |
| println!("Failed to parse recipe {:?}: {}", path, e); | |
| } | |
| } | |
| } | |
| } | |
| let total_recipes: usize = book.values().map(|v| v.len()).sum(); | |
| println!("Loaded {} recipes.", total_recipes); | |
| book | |
| } | |
| fn process_raw_recipe(raw: RawRecipe, result_kind: Option<ItemKind>) -> Option<Recipe> { | |
| let matcher = if raw.recipe_type == "minecraft:crafting_shaped" { | |
| // Parse pattern from Value if it is an array | |
| let pattern_val = raw.pattern?; | |
| let pattern_strs: Vec<String> = serde_json::from_value(pattern_val).ok()?; | |
| let key_map = raw.key?; | |
| let height = pattern_strs.len(); | |
| let width = pattern_strs.iter().map(|s| s.len()).max().unwrap_or(0); | |
| let mut pattern: Vec<Vec<Ingredient>> = Vec::new(); | |
| for row_str in pattern_strs { | |
| let mut row_ingredients = Vec::new(); | |
| for char in row_str.chars() { | |
| if char == ' ' { | |
| // Empty slot represented by a special "Empty" ingredient? | |
| // Or we just strictly handle spaces. | |
| // For now, let's treat "Empty" implicitly in execution, | |
| // but here we need to store something. | |
| // Let's use an empty List as "Air/Nothing" | |
| row_ingredients.push(Ingredient::List(vec![])); | |
| } else { | |
| let key = char.to_string(); | |
| if let Some(raw_ing) = key_map.get(&key) { | |
| if let Some(ing) = raw_ing.parse() { | |
| row_ingredients.push(ing); | |
| } else { | |
| // If key exists but fails parse (invalid item?), fail recipe | |
| return None; | |
| } | |
| } else { | |
| return None; // Key in pattern but not in key map | |
| } | |
| } | |
| } | |
| pattern.push(row_ingredients); | |
| } | |
| RecipeMatch::Shaped { | |
| width, | |
| height, | |
| pattern, | |
| } | |
| } else if raw.recipe_type == "minecraft:crafting_shapeless" { | |
| let ingredients = raw.ingredients?; | |
| let parsed_ingredients: Vec<Ingredient> = | |
| ingredients.into_iter().filter_map(|r| r.parse()).collect(); | |
| RecipeMatch::Shapeless { | |
| ingredients: parsed_ingredients, | |
| } | |
| } else if raw.recipe_type == "minecraft:crafting_transmute" { | |
| // Treated as shapeless with input + material | |
| let mut ing_list = Vec::new(); | |
| if let Some(i) = raw.input { | |
| if let Some(p) = i.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| if let Some(m) = raw.material { | |
| if let Some(p) = m.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| RecipeMatch::Shapeless { | |
| ingredients: ing_list, | |
| } | |
| } else if raw.recipe_type == "minecraft:stonecutting" | |
| || raw.recipe_type == "minecraft:smelting" | |
| || raw.recipe_type == "minecraft:smoking" | |
| || raw.recipe_type == "minecraft:blasting" | |
| || raw.recipe_type == "minecraft:campfire_cooking" | |
| { | |
| let mut ing_list = Vec::new(); | |
| if let Some(i) = raw.ingredient { | |
| if let Some(p) = i.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| RecipeMatch::Shapeless { | |
| ingredients: ing_list, | |
| } | |
| } else if raw.recipe_type == "minecraft:smithing_transform" | |
| || raw.recipe_type == "minecraft:smithing_trim" | |
| { | |
| // Treat as shapeless with base + template + addition (if present) | |
| let mut ing_list = Vec::new(); | |
| if let Some(b) = raw.base { | |
| if let Some(p) = b.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| if let Some(t) = raw.template { | |
| if let Some(p) = t.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| if let Some(a) = raw.addition { | |
| if let Some(p) = a.parse() { | |
| ing_list.push(p); | |
| } | |
| } | |
| RecipeMatch::Shapeless { | |
| ingredients: ing_list, | |
| } | |
| } else { | |
| // Unknown or unsupported type | |
| return None; | |
| }; | |
| Some(Recipe { | |
| result: raw.result.unwrap(), | |
| result_kind, | |
| matcher, | |
| }) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Default)] | |
| pub struct CraftItemParams { | |
| pub item_name: String, | |
| pub quantity: Option<f64>, | |
| pub use_station: Option<bool>, | |
| } | |
| pub struct SkillResult { | |
| pub success: bool, | |
| pub message: String, | |
| } | |
| pub struct SkillManager; | |
| impl SkillManager { | |
| /// Internal helper to ensure the bot is within reaching distance of a block. | |
| /// Uses ReachBlockPosGoal to stand close enough to interact. | |
| async fn ensure_reach(&self, bot: &Client, pos: BlockPos) -> bool { | |
| // First check if we are already close enough | |
| if bot.position().distance_to(pos.center()) <= 4.5 { | |
| return true; | |
| } | |
| let chunk_storage = bot.world().read().chunks.clone(); | |
| bot.start_goto(ReachBlockPosGoal::new(pos, chunk_storage)); | |
| // Wait with a timeout | |
| let wait_future = bot.wait_until_goto_target_reached(); | |
| match tokio::time::timeout(Duration::from_secs(15), wait_future).await { | |
| Ok(_) => { | |
| // Double check actual distance (ReachBlockPosGoal usually guarantees this) | |
| bot.position().distance_to(pos.center()) <= 5.5 | |
| } | |
| Err(_) => { | |
| bot.stop_pathfinding(); | |
| bot.position().distance_to(pos.center()) <= 5.5 | |
| } | |
| } | |
| } | |
| pub async fn craft_item(&self, bot: &Client, params: CraftItemParams) -> SkillResult { | |
| let quantity_wanted = params.quantity.unwrap_or(1.0) as u32; | |
| if quantity_wanted == 0 { | |
| return SkillResult { | |
| success: true, | |
| message: "Crafted 0 items as requested.".to_string(), | |
| }; | |
| } | |
| // --- 1. Find the recipe --- | |
| let target_item_name_full = if !params.item_name.contains(':') { | |
| format!("minecraft:{}", params.item_name) | |
| } else { | |
| params.item_name.clone() | |
| }; | |
| let target_item_kind = serde_json::from_value::<ItemKind>(serde_json::Value::String( | |
| target_item_name_full.clone(), | |
| )) | |
| .ok(); | |
| let target_item_kind = match target_item_kind { | |
| Some(kind) => kind, | |
| None => { | |
| let all_items = crate::RECIPES.book.keys(); | |
| let mut suggestions = Vec::new(); | |
| let query = params.item_name.to_lowercase(); | |
| for k in all_items { | |
| let name = get_item_name(*k); | |
| if name.contains(&query) { | |
| suggestions.push(name); | |
| } | |
| } | |
| suggestions.sort_by_key(|a| a.len()); | |
| let suggestions_msg = if !suggestions.is_empty() { | |
| format!( | |
| " Did you mean: {}?", | |
| suggestions | |
| .iter() | |
| .take(5) | |
| .cloned() | |
| .collect::<Vec<_>>() | |
| .join(", ") | |
| ) | |
| } else { | |
| String::new() | |
| }; | |
| return SkillResult { | |
| success: false, | |
| message: format!( | |
| "Unknown item name: {}.{}", | |
| params.item_name, suggestions_msg | |
| ), | |
| }; | |
| } | |
| }; | |
| let recipes = match crate::RECIPES.get_recipes_for(target_item_kind) { | |
| Some(recipes) => recipes, | |
| None => { | |
| return SkillResult { | |
| success: false, | |
| message: format!("No recipe found for '{}'", params.item_name), | |
| } | |
| } | |
| }; | |
| // --- 1.5 Station check --- | |
| let use_station = params.use_station.unwrap_or(true); | |
| let clean_name = params.item_name.replace("minecraft:", "").to_lowercase(); | |
| let stations = [ | |
| "crafting_table", | |
| "furnace", | |
| "blast_furnace", | |
| "smoker", | |
| "brewing_stand", | |
| "smithing_table", | |
| "fletching_table", | |
| "cartography_table", | |
| "grindstone", | |
| "loom", | |
| "stonecutter", | |
| ]; | |
| if use_station && stations.contains(&clean_name.as_str()) { | |
| if let Some(pos) = self.find_block_nearby(bot, &clean_name, 16) { | |
| return SkillResult { | |
| success: false, | |
| message: format!( | |
| "Found a {} nearby at ({}, {}, {}). Please use that. If you absolutely need to craft, set use_station=false to craft anyway.", | |
| clean_name, pos.x, pos.y, pos.z | |
| ), | |
| }; | |
| } | |
| } | |
| use crate::bot::recipes::{Ingredient, RecipeMatch}; | |
| // --- 2. Determine if 3x3 is required and open table if needed --- | |
| let mut needs_3x3 = false; | |
| for recipe in recipes { | |
| if let RecipeMatch::Shaped { width, height, .. } = &recipe.matcher { | |
| if *width > 2 || *height > 2 { | |
| needs_3x3 = true; | |
| break; | |
| } | |
| } | |
| } | |
| let mut inv = bot.get_inventory(); | |
| let mut _active_container = None; | |
| if needs_3x3 { | |
| let current_menu = inv | |
| .menu() | |
| .unwrap_or_else(|| Menu::Player(azalea::inventory::Player::default())); | |
| let is_player_inv = matches!(current_menu, Menu::Player(_)); | |
| if is_player_inv { | |
| println!( | |
| "3x3 required for {}. Searching for nearby crafting table...", | |
| params.item_name | |
| ); | |
| if let Some(table_pos) = self.find_block_nearby(bot, "crafting_table", 24) { | |
| println!("Found crafting table at {:?}. Opening...", table_pos); | |
| if self.ensure_reach(bot, table_pos).await { | |
| let handle = bot.open_container_at(table_pos).await; | |
| if let Some(h) = handle { | |
| _active_container = Some(h); | |
| inv = bot.get_inventory(); | |
| let mut opened = false; | |
| for _ in 0..20 { | |
| tokio::time::sleep(Duration::from_millis(100)).await; | |
| if let Some(m) = inv.menu() { | |
| if matches!(m, Menu::Crafting { .. }) { | |
| opened = true; | |
| break; | |
| } | |
| } | |
| } | |
| if !opened { | |
| return SkillResult { | |
| success: false, | |
| message: "Opened crafting table but menu did not appear." | |
| .to_string(), | |
| }; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // --- 3. Grid & Recipe Analysis --- | |
| let Some(menu) = inv.menu() else { | |
| return SkillResult { | |
| success: false, | |
| message: "Could not get inventory menu.".to_string(), | |
| }; | |
| }; | |
| let (inventory_slots, crafting_grid_slots, result_slot, grid_width): ( | |
| Vec<usize>, | |
| Vec<usize>, | |
| usize, | |
| usize, | |
| ) = match &menu { | |
| Menu::Player(_p) => { | |
| let inventory_slots = (9usize..=44usize).collect::<Vec<usize>>(); | |
| let crafting_grid_slots = (1usize..=4usize).collect::<Vec<usize>>(); | |
| let result_slot = 0usize; | |
| (inventory_slots, crafting_grid_slots, result_slot, 2usize) | |
| } | |
| Menu::Crafting { .. } => { | |
| let inventory_slots = (10usize..=45usize).collect::<Vec<usize>>(); | |
| let crafting_grid_slots = (1usize..=9usize).collect::<Vec<usize>>(); | |
| let result_slot = 0usize; | |
| (inventory_slots, crafting_grid_slots, result_slot, 3usize) | |
| } | |
| _ => { | |
| return SkillResult { | |
| success: false, | |
| message: "Not in a crafting menu.".to_string(), | |
| } | |
| } | |
| }; | |
| let grid_height = crafting_grid_slots.len() / grid_width; | |
| let mut selected_recipe = None; | |
| let mut ingredient_mapping: Vec<(usize, Ingredient)> = Vec::new(); | |
| for recipe in recipes { | |
| let mut mapping = Vec::new(); | |
| let mut fits = false; | |
| match &recipe.matcher { | |
| RecipeMatch::Shaped { | |
| width, | |
| height, | |
| pattern, | |
| } => { | |
| if *width <= grid_width && *height <= grid_height { | |
| fits = true; | |
| for (row_idx, row) in pattern.iter().enumerate() { | |
| for (col_idx, ing) in row.iter().enumerate() { | |
| let grid_idx = row_idx * grid_width + col_idx; | |
| if grid_idx < crafting_grid_slots.len() { | |
| let is_air = if let Ingredient::List(l) = ing { | |
| l.is_empty() | |
| } else { | |
| false | |
| }; | |
| if !is_air { | |
| mapping.push((grid_idx, ing.clone())); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| RecipeMatch::Shapeless { ingredients } => { | |
| if ingredients.len() <= crafting_grid_slots.len() { | |
| fits = true; | |
| for (i, ing) in ingredients.iter().enumerate() { | |
| mapping.push((i, ing.clone())); | |
| } | |
| } | |
| } | |
| } | |
| if fits { | |
| let mut temp_source_slots: HashMap<ItemKind, Vec<usize>> = HashMap::new(); | |
| for slot_index in inventory_slots.iter().cloned() { | |
| if let Some(ItemStack::Present(item_stack)) = menu.slot(slot_index) { | |
| temp_source_slots | |
| .entry(item_stack.kind) | |
| .or_insert_with(Vec::new) | |
| .push(slot_index); | |
| } | |
| } | |
| let mut temp_used: HashMap<usize, u32> = HashMap::new(); | |
| let mut all_ingredients_found = true; | |
| for (_grid_idx, ingredient) in &mapping { | |
| let mut found = false; | |
| 'find_ing: for (kind, slots) in &temp_source_slots { | |
| if ingredient.matches(*kind) { | |
| for &slot in slots { | |
| let used_count = *temp_used.get(&slot).unwrap_or(&0); | |
| if let Some(ItemStack::Present(stack)) = menu.slot(slot) { | |
| if stack.count as u32 > used_count { | |
| *temp_used.entry(slot).or_insert(0) += 1; | |
| found = true; | |
| break 'find_ing; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if !found { | |
| all_ingredients_found = false; | |
| break; | |
| } | |
| } | |
| if all_ingredients_found { | |
| ingredient_mapping = mapping; | |
| selected_recipe = Some(recipe); | |
| break; | |
| } | |
| } | |
| } | |
| if selected_recipe.is_none() { | |
| let mut reasons = Vec::new(); | |
| let mut best_missing_info = String::new(); | |
| let mut min_missing_count = usize::MAX; | |
| for recipe in recipes { | |
| let mut current_counts: HashMap<ItemKind, u32> = HashMap::new(); | |
| for slot in menu.contents() { | |
| if let ItemStack::Present(item) = slot { | |
| *current_counts.entry(item.kind).or_insert(0) += item.count as u32; | |
| } | |
| } | |
| let mut missing_report: Vec<String> = Vec::new(); | |
| let mut missing_total = 0; | |
| match &recipe.matcher { | |
| RecipeMatch::Shaped { pattern, .. } => { | |
| for row in pattern { | |
| for ing in row { | |
| if let Ingredient::List(l) = ing { | |
| if l.is_empty() { | |
| continue; | |
| } | |
| } | |
| let mut found = false; | |
| for (kind, count) in &mut current_counts { | |
| if *count > 0 && ing.matches(*kind) { | |
| *count -= 1; | |
| found = true; | |
| break; | |
| } | |
| } | |
| if !found { | |
| missing_total += 1; | |
| missing_report.push(format!("{}", ing)); | |
| } | |
| } | |
| } | |
| } | |
| RecipeMatch::Shapeless { ingredients } => { | |
| for ing in ingredients { | |
| let mut found = false; | |
| for (kind, count) in &mut current_counts { | |
| if *count > 0 && ing.matches(*kind) { | |
| *count -= 1; | |
| found = true; | |
| break; | |
| } | |
| } | |
| if !found { | |
| missing_total += 1; | |
| missing_report.push(format!("{}", ing)); | |
| } | |
| } | |
| } | |
| } | |
| if missing_total < min_missing_count { | |
| min_missing_count = missing_total; | |
| if !missing_report.is_empty() { | |
| let mut missing_counts: HashMap<String, usize> = HashMap::new(); | |
| for item in missing_report { | |
| *missing_counts.entry(item).or_insert(0) += 1; | |
| } | |
| let mut formatted_missing: Vec<String> = missing_counts | |
| .iter() | |
| .map(|(name, count)| format!("{}x {}", count, name)) | |
| .collect(); | |
| formatted_missing.sort(); | |
| best_missing_info = format!("Missing: {}", formatted_missing.join(", ")); | |
| } | |
| } | |
| } | |
| if !best_missing_info.is_empty() { | |
| reasons.push(best_missing_info); | |
| } | |
| // Fallback for logic: if 3x3 is absolutely required AND we are not in 3x3, and that is the only reason, we warn. | |
| // But if ingredients are missing, that's the primary error. | |
| if reasons.is_empty() && grid_width < 3 && needs_3x3 { | |
| reasons.push("Requires a 3x3 crafting grid (crafting table)".to_string()); | |
| } | |
| let message = if reasons.is_empty() { | |
| format!("No compatible recipe found for '{}'.", params.item_name) | |
| } else { | |
| format!( | |
| "No compatible recipe found for '{}'. {}", | |
| params.item_name, | |
| reasons.join(". ") | |
| ) | |
| }; | |
| return SkillResult { | |
| success: false, | |
| message: format!( | |
| "No compatible recipe/ingredients for '{}'.", | |
| params.item_name | |
| ), | |
| }; | |
| } | |
| let final_recipe = selected_recipe.unwrap(); | |
| let produced_per_craft = final_recipe.result.count; | |
| let batches_needed = (quantity_wanted as f64 / produced_per_craft as f64).ceil() as u32; | |
| for i in 0..batches_needed { | |
| let mut source_slots: HashMap<ItemKind, Vec<usize>> = HashMap::new(); | |
| let menu = match inv.menu() { | |
| Some(m) => m, | |
| None => { | |
| return SkillResult { | |
| success: false, | |
| message: "Inventory closed unexpectedly".into(), | |
| } | |
| } | |
| }; | |
| for slot_index in inventory_slots.iter().cloned() { | |
| if let Some(ItemStack::Present(item_stack)) = menu.slot(slot_index) { | |
| source_slots | |
| .entry(item_stack.kind) | |
| .or_insert_with(Vec::new) | |
| .push(slot_index); | |
| } | |
| } | |
| let mut used_source_slots: HashMap<usize, u32> = HashMap::new(); | |
| for (grid_relative_idx, ingredient) in &ingredient_mapping { | |
| let target_grid_slot = crafting_grid_slots[*grid_relative_idx]; | |
| let mut found_source_slot = None; | |
| 'search_source: for (kind, slots) in &source_slots { | |
| if ingredient.matches(*kind) { | |
| for &slot in slots { | |
| let already_used = *used_source_slots.get(&slot).unwrap_or(&0); | |
| if let Some(ItemStack::Present(stack)) = menu.slot(slot) { | |
| if stack.count as u32 > already_used { | |
| found_source_slot = Some(slot); | |
| break 'search_source; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if let Some(s_slot) = found_source_slot { | |
| inv.left_click(s_slot); | |
| tokio::time::sleep(Duration::from_millis(60)).await; | |
| inv.right_click(target_grid_slot); | |
| tokio::time::sleep(Duration::from_millis(60)).await; | |
| inv.left_click(s_slot); | |
| tokio::time::sleep(Duration::from_millis(60)).await; | |
| *used_source_slots.entry(s_slot).or_insert(0) += 1; | |
| } else { | |
| return SkillResult { | |
| success: false, | |
| message: format!("Craft #{} failed. Missing ingredient.", i + 1), | |
| }; | |
| } | |
| } | |
| // Wait for result | |
| let mut result_ready = false; | |
| for _ in 0..60 { | |
| tokio::time::sleep(Duration::from_millis(50)).await; | |
| if let Some(updated_menu) = inv.menu() { | |
| if let Some(res) = updated_menu.slot(result_slot) { | |
| if !res.is_empty() { | |
| result_ready = true; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| if !result_ready { | |
| return SkillResult { | |
| success: false, | |
| message: format!( | |
| "Craft #{} failed. No result in output slot (server timeout?).", | |
| i + 1 | |
| ), | |
| }; | |
| } | |
| inv.shift_click(result_slot); | |
| tokio::time::sleep(Duration::from_millis(200)).await; | |
| } | |
| SkillResult { | |
| success: true, | |
| message: format!( | |
| "Crafted {}x {}", | |
| batches_needed * produced_per_craft, | |
| params.item_name | |
| ), | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use azalea::registry::builtin::ItemKind; | |
| /// Helper to get a clean snake_case name for an ItemKind (e.g. "spruce_log") | |
| pub fn get_item_name(kind: ItemKind) -> String { | |
| // ItemKind implements Serialize, which outputs the registry name (e.g., "minecraft:spruce_log") | |
| let s = serde_json::to_string(&kind).unwrap_or_else(|_| format!("{:?}", kind)); | |
| s.trim_matches('"').replace("minecraft:", "") | |
| } | |
| /// Heuristic to check if an item/block name matches a tag. | |
| /// This is used because we don't always have access to the full server tag registry in this context. | |
| pub fn matches_tag(item_name: &str, tag: &str) -> bool { | |
| let item_name = item_name.replace("minecraft:", "").to_lowercase(); | |
| let tag_clean = tag.replace("#", ""); // Remove leading # if present | |
| // Exact match for direct tag usage in some contexts | |
| if item_name == tag_clean { | |
| return true; | |
| } | |
| // Specific heuristics for common Minecraft tags | |
| if tag_clean == "minecraft:planks" && item_name.ends_with("_planks") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:logs" && item_name.ends_with("_log") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:wool" && item_name.ends_with("_wool") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:wooden_tool_materials" && item_name.ends_with("_planks") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:stone_tool_materials" | |
| && (item_name == "cobblestone" | |
| || item_name == "blackstone" | |
| || item_name == "cobbled_deepslate") | |
| { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:leaves" && item_name.ends_with("_leaves") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:fences" && item_name.ends_with("_fence") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:fence_gates" && item_name.ends_with("_fence_gate") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:doors" && item_name.ends_with("_door") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:trapdoors" && item_name.ends_with("_trapdoor") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:slabs" && item_name.ends_with("_slab") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:stairs" && item_name.ends_with("_stairs") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:buttons" && item_name.ends_with("_button") { | |
| return true; | |
| } | |
| if tag_clean == "minecraft:pressure_plates" && item_name.ends_with("_pressure_plate") { | |
| return true; | |
| } | |
| // Fallback: check if item name contains the tag name (very loose, but better than false) | |
| let tag_short = tag_clean.strip_prefix("minecraft:").unwrap_or(&tag_clean); | |
| // Handle plural "logs" matching singular "log" | |
| if tag_short.ends_with('s') { | |
| let singular = &tag_short[..tag_short.len() - 1]; // remove 's' | |
| if item_name.contains(singular) { | |
| return true; | |
| } | |
| } | |
| if item_name.contains(tag_short) { | |
| return true; | |
| } | |
| false | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment