use heck::{ToPascalCase, ToShoutySnakeCase}; use indexmap::IndexMap; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use serde::Deserialize; use serde_json::Value; use std::fs; #[derive(Deserialize)] struct VillagerDataJson { professions: IndexMap, types: IndexMap, } #[derive(Deserialize)] struct ProfessionJson { name: NameJson, requested_items: Vec, work_sound: Option, #[serde(default)] trade_sets: IndexMap, } #[derive(Deserialize)] struct NameJson { translate: String, } #[derive(Deserialize, Clone)] struct TradeSetJson { trades: String, // Tag like "#minecraft:armorer/level_1" #[serde(default = "default_amount")] amount: f32, } fn default_amount() -> f32 { 1.0 } #[derive(Deserialize, Clone)] struct TradeJson { wants: TradeItemJson, #[serde(alias = "wants_b")] additional_wants: Option, gives: TradeItemJson, max_uses: Option, xp: Option, #[serde(alias = "price_multiplier")] reputation_discount: Option, #[serde(default)] given_item_modifiers: Vec, merchant_predicate: Option, } #[derive(Deserialize, Clone)] struct TradeItemJson { id: String, count: Option, } fn walk_json_files(dir: &std::path::Path, base_dir: &std::path::Path, callback: &mut F) where F: FnMut(String, String), { if let Ok(entries) = fs::read_dir(dir) { let mut entries: Vec<_> = entries.flatten().collect(); entries.sort_by_key(|e| e.path()); for entry in entries { let path = entry.path(); if path.is_dir() { walk_json_files(&path, base_dir, callback); } else if path.extension().is_some_and(|ext| ext == "json") { let rel = path.strip_prefix(base_dir).unwrap(); let stem = rel.with_extension(""); let key = stem.to_string_lossy().replace('\\', "/"); let content = fs::read_to_string(&path).expect("Failed to read JSON file"); callback(key, content); } } } } pub fn build() -> TokenStream { let data: VillagerDataJson = serde_json::from_str(&fs::read_to_string("../../assets/villager_data.json").unwrap()) .expect("Failed to parse villager_data.json"); // Load trade sets from datapack let trade_set_dir = std::path::Path::new("../../assets/datapacks/26_2/data/minecraft/trade_set"); let mut trade_sets: IndexMap = IndexMap::new(); walk_json_files(trade_set_dir, trade_set_dir, &mut |key, content| { if let Ok(trade_set) = serde_json::from_str::(&content) { trade_sets.insert(format!("minecraft:{key}"), trade_set.clone()); trade_sets.insert(key, trade_set); } }); // Load trade tags from datapack let trade_tags_dir = std::path::Path::new("../../assets/datapacks/26_2/data/minecraft/tags/villager_trade"); let mut raw_trade_tags: IndexMap> = IndexMap::new(); walk_json_files(trade_tags_dir, trade_tags_dir, &mut |key, content| { #[derive(Deserialize)] struct TagFile { values: Vec, } if let Ok(tag_file) = serde_json::from_str::(&content) { let values = tag_file .values .into_iter() .filter_map(|v| match v { Value::String(s) => Some(s), Value::Object(obj) => obj.get("id").and_then(Value::as_str).map(String::from), _ => None, }) .collect(); raw_trade_tags.insert(format!("minecraft:{key}"), values); } }); fn resolve_trade_tag( tag: &str, raw_tags: &IndexMap>, visited: &mut std::collections::HashSet, ) -> Vec { if visited.contains(tag) { return Vec::new(); } visited.insert(tag.to_string()); let mut result = Vec::new(); let full_tag = if tag.starts_with("minecraft:") { tag.to_string() } else { format!("minecraft:{tag}") }; if let Some(values) = raw_tags.get(&full_tag) { for v in values { if let Some(sub) = v.strip_prefix('#') { let mut sub_visited = visited.clone(); for child in resolve_trade_tag(sub, raw_tags, &mut sub_visited) { if !result.contains(&child) { result.push(child); } } } else { let full_v = if v.starts_with("minecraft:") { v.clone() } else { format!("minecraft:{v}") }; if !result.contains(&full_v) { result.push(full_v); } } } } result } // Load individual villager trades from datapack let trades_dir = std::path::Path::new("../../assets/datapacks/26_2/data/minecraft/villager_trade"); let mut villager_trades: IndexMap = IndexMap::new(); walk_json_files(trades_dir, trades_dir, &mut |key, content| { if let Ok(trade) = serde_json::from_str::(&content) { villager_trades.insert(format!("minecraft:{key}"), trade.clone()); villager_trades.insert(key, trade); } }); let mut profession_variants = Vec::new(); let mut type_variants = Vec::new(); let mut work_sounds = Vec::new(); let mut requested_items = Vec::new(); let mut profession_names = Vec::new(); let mut profession_from_i32 = Vec::new(); let mut type_from_i32 = Vec::new(); let mut trade_set_data = Vec::new(); let mut generated_trade_sets = IndexMap::new(); // Helper to format a trade into TokenStream let format_trade = |trade: &TradeJson| { let wants_item = format_ident!( "{}", trade .wants .id .strip_prefix("minecraft:") .unwrap_or(&trade.wants.id) .to_shouty_snake_case() ); let wants_count = trade.wants.count.unwrap_or(1.0) as i32; let wants = quote! { VillagerTradeItem { item: &crate::item::Item::#wants_item, count: #wants_count } }; let wants_b = if let Some(b) = &trade.additional_wants { let item = format_ident!( "{}", b.id.strip_prefix("minecraft:") .unwrap_or(&b.id) .to_shouty_snake_case() ); let count = b.count.unwrap_or(1.0) as i32; quote! { Some(VillagerTradeItem { item: &crate::item::Item::#item, count: #count }) } } else { quote! { None } }; let gives_item = format_ident!( "{}", trade .gives .id .strip_prefix("minecraft:") .unwrap_or(&trade.gives.id) .to_shouty_snake_case() ); let gives_count = trade.gives.count.unwrap_or(1.0) as i32; let gives = quote! { VillagerTradeItem { item: &crate::item::Item::#gives_item, count: #gives_count } }; let max_uses = trade.max_uses.unwrap_or(16.0) as i32; let xp = trade.xp.unwrap_or(2.0) as i32; let price_multiplier = trade.reputation_discount.unwrap_or(0.05); let modifier = trade .given_item_modifiers .iter() .find_map(|modifier| { let function = modifier.get("function")?.as_str()?; Some(match function { "minecraft:enchant_randomly" => quote! { VillagerTradeModifier::EnchantRandomly }, "minecraft:enchant_with_levels" => { let levels = modifier.get("levels")?; let min = levels.get("min")?.as_f64()? as i32; let max = levels.get("max")?.as_f64()? as i32; quote! { VillagerTradeModifier::EnchantWithLevels { min: #min, max: #max } } } "minecraft:exploration_map" => { let destination = modifier.get("destination")?.as_str()?; quote! { VillagerTradeModifier::ExplorationMap { destination: #destination } } } "minecraft:set_random_dyes" => quote! { VillagerTradeModifier::RandomDyes }, "minecraft:set_random_potion" => quote! { VillagerTradeModifier::RandomPotion }, "minecraft:set_stew_effect" => quote! { VillagerTradeModifier::SuspiciousStew }, "minecraft:set_potion" => { let potion = modifier.get("id")?.as_str()?; quote! { VillagerTradeModifier::Potion(#potion) } } _ => return None, }) }) .unwrap_or_else(|| quote! { VillagerTradeModifier::None }); let allowed_types = trade .merchant_predicate .as_ref() .and_then(|predicate| { predicate.pointer("/predicate/minecraft:predicates/minecraft:villager~1variant") }) .map(|variants| { let variants: Vec<_> = variants .as_array() .map_or_else(|| vec![variants], |variants| variants.iter().collect()) .into_iter() .filter_map(Value::as_str) .map(|variant| { let ident = format_ident!( "{}", variant .strip_prefix("minecraft:") .unwrap_or(variant) .to_pascal_case() ); quote! { VillagerType::#ident } }) .collect(); quote! { &[#(#variants),*] } }) .unwrap_or_else(|| quote! { &[] }); quote! { VillagerTrade { wants: #wants, wants_b: #wants_b, gives: #gives, max_uses: #max_uses, xp: #xp, price_multiplier: #price_multiplier, modifier: #modifier, allowed_types: #allowed_types, } } }; // Pre-process all trade sets mentioned in trade_sets map for (_set_key, set_data) in &trade_sets { let tag = &set_data.trades; if generated_trade_sets.contains_key(tag) { continue; } let tag_content = tag.strip_prefix('#').unwrap_or(tag); let tag_clean = tag_content .strip_prefix("minecraft:") .unwrap_or(tag_content); let mut visited = std::collections::HashSet::new(); let trade_ids = resolve_trade_tag(tag_clean, &raw_trade_tags, &mut visited); let mut matching_trades = Vec::new(); for trade_id in trade_ids { if let Some(trade) = villager_trades.get(&trade_id) { matching_trades.push(format_trade(trade)); } } if !matching_trades.is_empty() { let ident_name = tag_clean.replace('/', "_").to_shouty_snake_case(); let ident = format_ident!("TRADES_{}", ident_name); trade_set_data.push(quote! { pub const #ident: &[VillagerTrade] = &[ #(#matching_trades),* ]; }); generated_trade_sets.insert(tag.clone(), ident); } } let mut profession_trade_sets = Vec::new(); for (i, (name, prof_data)) in data.professions.iter().enumerate() { let ident = format_ident!("{}", name.to_pascal_case()); profession_variants.push(quote! { #ident }); let sound = if let Some(sound) = &prof_data.work_sound { let sound_ident = format_ident!( "{}", sound .strip_prefix("minecraft:") .unwrap_or(sound) .replace('.', "_") .to_pascal_case() ); quote! { Some(crate::sound::Sound::#sound_ident) } } else { quote! { None } }; work_sounds.push(quote! { Self::#ident => #sound }); let items: Vec<_> = prof_data .requested_items .iter() .map(|i| { let item_ident = format_ident!( "{}", i.strip_prefix("minecraft:") .unwrap_or(i) .to_shouty_snake_case() ); quote! { &crate::item::Item::#item_ident } }) .collect(); requested_items.push(quote! { Self::#ident => &[#(#items),*] }); let translate = &prof_data.name.translate; profession_names.push(quote! { Self::#ident => #translate }); let i = i as i32; profession_from_i32.push(quote! { #i => Some(Self::#ident) }); let mut level_matches = Vec::new(); for (level_str, set_key) in &prof_data.trade_sets { let level = level_str.parse::().unwrap(); let set_key_clean = set_key.strip_prefix("minecraft:").unwrap_or(set_key); if let Some(trades_ident) = trade_sets .get(set_key_clean) .and_then(|set| generated_trade_sets.get(&set.trades)) { let set = trade_sets.get(set_key_clean).unwrap(); let amount = set.amount as i32; level_matches.push(quote! { #level => Some(VillagerTradeSet { trades: #trades_ident, amount: #amount }) }); } } let profession_trade_set = if level_matches.is_empty() { quote! { Self::#ident => None } } else { quote! { Self::#ident => match level { #(#level_matches,)* _ => None, } } }; profession_trade_sets.push(profession_trade_set); } for (i, name) in data.types.keys().enumerate() { let ident = format_ident!("{}", name.to_pascal_case()); type_variants.push(quote! { #ident }); let i = i as i32; type_from_i32.push(quote! { #i => Some(Self::#ident) }); } quote! { use serde::Serialize; #[derive(Clone, Copy, PartialEq, Eq)] pub struct VillagerTradeItem { pub item: &'static crate::item::Item, pub count: i32, } #[derive(Clone, Copy, PartialEq)] pub struct VillagerTrade { pub wants: VillagerTradeItem, pub wants_b: Option, pub gives: VillagerTradeItem, pub max_uses: i32, pub xp: i32, pub price_multiplier: f32, pub modifier: VillagerTradeModifier, pub allowed_types: &'static [VillagerType], } #[derive(Clone, Copy, PartialEq, Eq)] pub enum VillagerTradeModifier { None, EnchantRandomly, EnchantWithLevels { min: i32, max: i32 }, ExplorationMap { destination: &'static str }, RandomDyes, RandomPotion, SuspiciousStew, Potion(&'static str), } #[derive(Clone, Copy, PartialEq)] pub struct VillagerTradeSet { pub trades: &'static [VillagerTrade], pub amount: i32, } #(#trade_set_data)* #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[repr(i32)] pub enum VillagerProfession { #(#profession_variants),* } impl VillagerProfession { #[must_use] pub const fn from_i32(id: i32) -> Option { match id { #(#profession_from_i32,)* _ => None, } } #[must_use] #[allow(clippy::match_same_arms)] pub const fn work_sound(&self) -> Option { match self { #(#work_sounds),* } } #[must_use] #[allow(clippy::match_same_arms)] pub const fn requested_items(&self) -> &'static [&'static crate::item::Item] { match self { #(#requested_items),* } } #[must_use] pub const fn translation_key(&self) -> &'static str { match self { #(#profession_names),* } } #[must_use] #[allow(clippy::too_many_lines, clippy::match_same_arms)] pub const fn trade_set(&self, level: i32) -> Option { match self { #(#profession_trade_sets,)* } } } impl TryFrom for VillagerProfession { type Error = (); fn try_from(value: i32) -> Result { Self::from_i32(value).ok_or(()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[repr(i32)] pub enum VillagerType { #(#type_variants),* } impl VillagerType { #[must_use] pub const fn from_i32(id: i32) -> Option { match id { #(#type_from_i32,)* _ => None, } } } impl TryFrom for VillagerType { type Error = (); fn try_from(value: i32) -> Result { Self::from_i32(value).ok_or(()) } } } }