Skip to content

Instantly share code, notes, and snippets.

@requizm
Last active January 29, 2026 18:03
Show Gist options
  • Select an option

  • Save requizm/5827851337b22bd6b5b725847bb4eb01 to your computer and use it in GitHub Desktop.

Select an option

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
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,
})
}
#[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
),
}
}
}
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