use std::{fs, path::Path}; use heck::ToShoutySnakeCase; use proc_macro2::{Span, TokenStream}; use pumpkin_util::loot_table::{LootBonusFormula, LootCondition}; use quote::{format_ident, quote}; use serde::Deserialize; use syn::LitStr; /// `rolls` can be a bare float or an object with `type/min/max`. #[derive(Deserialize, Clone, Debug)] #[serde(untagged)] enum RollsStruct { Constant(f32), Provider { #[allow(dead_code)] #[serde(rename = "type")] provider_type: String, #[allow(dead_code)] #[serde(default)] min: f32, #[serde(default)] max: f32, }, } impl RollsStruct { fn min(&self) -> i32 { match self { Self::Constant(v) => v.round() as i32, Self::Provider { min, .. } => min.round() as i32, } } fn max(&self) -> i32 { match self { Self::Constant(v) => v.round() as i32, Self::Provider { max, .. } => max.round() as i32, } } } /// A `set_count` count provider (uniform or constant). #[derive(Deserialize, Clone, Debug)] #[serde(untagged)] enum CountStruct { Constant(f32), Provider { #[serde(rename = "type")] #[allow(dead_code)] provider_type: String, #[serde(default)] min: f32, #[serde(default)] max: f32, }, } impl CountStruct { fn min(&self) -> i32 { match self { Self::Constant(v) => v.round() as i32, Self::Provider { min, .. } => min.round() as i32, } } fn max(&self) -> i32 { match self { Self::Constant(v) => v.round() as i32, Self::Provider { max, .. } => max.round() as i32, } } } #[derive(Deserialize, Clone, Debug)] struct PredicateStruct { #[serde(default)] items: Option, #[serde(default)] predicates: Option, } #[derive(Deserialize, Clone, Debug)] #[serde(untagged)] enum EnchantedChanceStruct { Constant(f32), Linear { #[serde(rename = "type")] chance_type: String, base: f32, #[serde(default)] per_level_above_first: f32, }, } #[derive(Deserialize, Clone, Debug)] struct ConditionStruct { #[serde(default)] condition: String, #[allow(dead_code)] #[serde(default)] enchantment: Option, #[serde(default)] chance: Option, #[serde(default)] unenchanted_chance: Option, #[serde(default)] enchanted_chance: Option, #[allow(dead_code)] #[serde(default)] chances: Option>, #[serde(default)] predicate: Option, #[serde(default)] term: Option>, #[serde(default)] terms: Option>, } fn parse_condition(cond: &ConditionStruct) -> LootCondition { match cond.condition.as_str() { "minecraft:survives_explosion" => LootCondition::SurvivesExplosion, "minecraft:killed_by_player" => LootCondition::KilledByPlayer, "minecraft:random_chance" => { let chance = cond .chance .or_else(|| cond.chances.as_ref().and_then(|c| c.first().copied())) .unwrap_or(0.0); LootCondition::RandomChance { chance } } "minecraft:random_chance_with_enchanted_bonus" => { let unenchanted_chance = cond.unenchanted_chance.unwrap_or(0.0); let (enchanted_chance_base, enchanted_chance_per_level_above_first) = match &cond.enchanted_chance { Some(EnchantedChanceStruct::Linear { base, per_level_above_first, .. }) => (*base, *per_level_above_first), Some(EnchantedChanceStruct::Constant(c)) => (*c, 0.0), None => (unenchanted_chance, 0.0), }; LootCondition::RandomChanceWithEnchantedBonus { unenchanted_chance, enchanted_chance_base, enchanted_chance_per_level_above_first, } } "minecraft:all_of" => { if let Some(terms) = &cond.terms { combine_conditions(terms) } else { LootCondition::None } } "minecraft:match_tool" => { if let Some(pred) = &cond.predicate { if let Some(items_val) = &pred.items { let is_shears = match items_val { serde_json::Value::String(s) => s.contains("shears"), serde_json::Value::Array(arr) => arr .iter() .any(|v| v.as_str().is_some_and(|s| s.contains("shears"))), _ => false, }; if is_shears { return LootCondition::Shears; } } if let Some(pred_val) = &pred.predicates { let s = pred_val.to_string(); if s.contains("silk_touch") { return LootCondition::SilkTouch; } } } LootCondition::None } "minecraft:any_of" => { if let Some(terms) = &cond.terms { let has_silk = terms .iter() .any(|t| parse_condition(t) == LootCondition::SilkTouch); let has_shears = terms .iter() .any(|t| parse_condition(t) == LootCondition::Shears); if has_silk && has_shears { return LootCondition::SilkTouchOrShears; } else if has_silk { return LootCondition::SilkTouch; } else if has_shears { return LootCondition::Shears; } } LootCondition::None } "minecraft:inverted" => { if let Some(term) = &cond.term { match parse_condition(term) { LootCondition::SilkTouch => LootCondition::NoSilkTouch, LootCondition::Shears => LootCondition::NoSilkTouchOrShears, LootCondition::SilkTouchOrShears => LootCondition::NoSilkTouchOrShears, _ => LootCondition::None, } } else { LootCondition::None } } _ => LootCondition::None, } } fn combine_conditions(conditions: &[ConditionStruct]) -> LootCondition { let mut parsed_list: Vec = Vec::new(); for c in conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { parsed_list.push(parsed); } } match parsed_list.len() { 0 => LootCondition::None, 1 => parsed_list[0], _ => LootCondition::AllOf(Box::leak(parsed_list.into_boxed_slice())), } } #[derive(Deserialize, Clone, Debug)] struct BonusParameterStruct { #[serde(rename = "bonusMultiplier", default)] bonus_multiplier: Option, #[serde(default)] extra: Option, #[serde(default)] probability: Option, } #[derive(Deserialize, Clone, Debug)] struct EntryFunctionStruct { function: String, #[serde(default)] formula: Option, #[serde(default)] parameters: Option, count: Option, } #[derive(Deserialize, Clone, Debug)] #[serde(untagged)] enum LootTableValue { Reference(String), Inline(ChestLootTableJson), } #[derive(Deserialize, Clone, Debug)] struct PoolEntryStruct { #[serde(rename = "type")] entry_type: String, name: Option, #[serde(default)] value: Option, #[serde(default = "default_weight")] weight: i32, #[serde(default)] functions: Vec, #[serde(default)] conditions: Vec, #[serde(default)] children: Vec, } fn default_weight() -> i32 { 1 } #[derive(Deserialize, Clone, Debug)] struct PoolStruct { #[serde(default)] entries: Vec, #[serde(default = "default_rolls")] rolls: RollsStruct, #[serde(default)] conditions: Vec, } fn default_rolls() -> RollsStruct { RollsStruct::Constant(1.0) } #[derive(Deserialize, Clone, Debug)] struct ChestLootTableJson { #[serde(default)] pools: Vec, } fn path_to_key(relative: &str) -> String { format!("minecraft:{relative}") } fn path_to_ident(relative: &str) -> String { relative.replace('/', "_").to_shouty_snake_case() } struct ParsedEntry { item: String, weight: i32, min_count: i32, max_count: i32, condition: LootCondition, bonus_formula: Option, } fn extract_entries( entry: &PoolEntryStruct, inherited_condition: LootCondition, out: &mut Vec, empty_weight: &mut i32, ) { extract_entries_with_depth(entry, inherited_condition, out, empty_weight, 0); } fn extract_entries_with_depth( entry: &PoolEntryStruct, inherited_condition: LootCondition, out: &mut Vec, empty_weight: &mut i32, depth: usize, ) { if depth > 5 { return; } let mut entry_cond = inherited_condition; for c in &entry.conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { if entry_cond == LootCondition::NoSilkTouch || entry_cond == LootCondition::NoSilkTouchOrShears { } else { entry_cond = parsed; } } } match entry.entry_type.as_str() { "minecraft:empty" => { *empty_weight += entry.weight; } "minecraft:item" => { if let Some(name) = &entry.name { let (min_count, max_count) = entry .functions .iter() .find(|f| f.function == "minecraft:set_count") .and_then(|f| f.count.as_ref()) .map(|c| (c.min(), c.max())) .unwrap_or((1, 1)); let bonus_formula = entry.functions.iter().find_map(|f| { if f.function == "minecraft:apply_bonus" { match f.formula.as_deref() { Some("minecraft:ore_drops") => Some(LootBonusFormula::OreDrops), Some("minecraft:uniform_bonus_count") => { let mult = f .parameters .as_ref() .and_then(|p| p.bonus_multiplier) .unwrap_or(1); Some(LootBonusFormula::UniformBonusCount(mult)) } Some("minecraft:binomial_with_bonus_count") => { let extra = f.parameters.as_ref().and_then(|p| p.extra).unwrap_or(0); let prob = f .parameters .as_ref() .and_then(|p| p.probability) .unwrap_or(0.0); Some(LootBonusFormula::BinomialWithBonusCount { extra, probability: prob, }) } _ => None, } } else if f.function == "minecraft:enchanted_count_increase" { let mult = f.count.as_ref().map_or(1, |c| c.max()); Some(LootBonusFormula::UniformBonusCount(mult)) } else { None } }); out.push(ParsedEntry { item: name.clone(), weight: entry.weight, min_count, max_count, condition: entry_cond, bonus_formula, }); } } "minecraft:tag" => { let tag_name_opt = entry.name.as_deref().or_else(|| match &entry.value { Some(LootTableValue::Reference(r)) => Some(r.as_str()), _ => None, }); if let Some(tag_name) = tag_name_opt { let tag_rel = tag_name.strip_prefix("minecraft:").unwrap_or(tag_name); let tag_path = Path::new("../../assets/datapacks/26_2/data/minecraft/tags/item") .join(format!("{tag_rel}.json")); if let Ok(content) = fs::read_to_string(&tag_path) { #[derive(Deserialize)] struct TagJson { values: Vec, } if let Ok(tag_data) = serde_json::from_str::(&content) { for item_name in tag_data.values { out.push(ParsedEntry { item: item_name, weight: entry.weight, min_count: 1, max_count: 1, condition: entry_cond, bonus_formula: None, }); } } } } } "minecraft:loot_table" => match &entry.value { Some(LootTableValue::Reference(table_name)) => { let table_rel = table_name.strip_prefix("minecraft:").unwrap_or(table_name); let table_path = Path::new("../../assets/datapacks/26_2/data/minecraft/loot_table") .join(format!("{table_rel}.json")); if let Ok(content) = fs::read_to_string(&table_path) { if let Ok(nested_table) = serde_json::from_str::(&content) { for pool in &nested_table.pools { let mut pool_cond = entry_cond; for c in &pool.conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { pool_cond = parsed; } } for child_entry in &pool.entries { extract_entries_with_depth( child_entry, pool_cond, out, empty_weight, depth + 1, ); } } } } } Some(LootTableValue::Inline(nested_table)) => { for pool in &nested_table.pools { let mut pool_cond = entry_cond; for c in &pool.conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { pool_cond = parsed; } } for child_entry in &pool.entries { extract_entries_with_depth( child_entry, pool_cond, out, empty_weight, depth + 1, ); } } } None => { if let Some(name) = &entry.name { let table_rel = name.strip_prefix("minecraft:").unwrap_or(name); let table_path = Path::new("../../assets/datapacks/26_2/data/minecraft/loot_table") .join(format!("{table_rel}.json")); if let Ok(content) = fs::read_to_string(&table_path) { if let Ok(nested_table) = serde_json::from_str::(&content) { for pool in &nested_table.pools { let mut pool_cond = entry_cond; for c in &pool.conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { pool_cond = parsed; } } for child_entry in &pool.entries { extract_entries_with_depth( child_entry, pool_cond, out, empty_weight, depth + 1, ); } } } } } } }, "minecraft:alternatives" => { let mut saw_silk = false; let mut saw_shears = false; for child in &entry.children { let mut child_cond = LootCondition::None; for c in &child.conditions { let parsed = parse_condition(c); if parsed != LootCondition::None { child_cond = parsed; } } let effective_cond = if child_cond == LootCondition::SilkTouch { saw_silk = true; LootCondition::SilkTouch } else if child_cond == LootCondition::Shears { saw_shears = true; LootCondition::Shears } else if child_cond == LootCondition::SilkTouchOrShears { saw_silk = true; saw_shears = true; LootCondition::SilkTouchOrShears } else if saw_silk && saw_shears { LootCondition::NoSilkTouchOrShears } else if saw_silk { LootCondition::NoSilkTouch } else if saw_shears { LootCondition::NoSilkTouchOrShears } else { entry_cond }; extract_entries_with_depth(child, effective_cond, out, empty_weight, depth + 1); } } "minecraft:sequence" | "minecraft:group" => { for child in &entry.children { extract_entries_with_depth(child, entry_cond, out, empty_weight, depth + 1); } } _ => {} } } fn condition_to_tokens(cond: LootCondition) -> TokenStream { match cond { LootCondition::None => quote! { LootCondition::None }, LootCondition::SilkTouch => quote! { LootCondition::SilkTouch }, LootCondition::NoSilkTouch => quote! { LootCondition::NoSilkTouch }, LootCondition::Shears => quote! { LootCondition::Shears }, LootCondition::SilkTouchOrShears => quote! { LootCondition::SilkTouchOrShears }, LootCondition::NoSilkTouchOrShears => quote! { LootCondition::NoSilkTouchOrShears }, LootCondition::SurvivesExplosion => quote! { LootCondition::SurvivesExplosion }, LootCondition::KilledByPlayer => quote! { LootCondition::KilledByPlayer }, LootCondition::RandomChance { chance } => { quote! { LootCondition::RandomChance { chance: #chance } } } LootCondition::RandomChanceWithEnchantedBonus { unenchanted_chance, enchanted_chance_base, enchanted_chance_per_level_above_first, } => { quote! { LootCondition::RandomChanceWithEnchantedBonus { unenchanted_chance: #unenchanted_chance, enchanted_chance_base: #enchanted_chance_base, enchanted_chance_per_level_above_first: #enchanted_chance_per_level_above_first, } } } LootCondition::AllOf(list) => { let tokens: Vec = list.iter().copied().map(condition_to_tokens).collect(); quote! { LootCondition::AllOf(&[#(#tokens),*]) } } } } fn bonus_to_tokens(bonus: Option) -> TokenStream { match bonus { None => quote! { None }, Some(LootBonusFormula::OreDrops) => { quote! { Some(LootBonusFormula::OreDrops) } } Some(LootBonusFormula::UniformBonusCount(mult)) => { quote! { Some(LootBonusFormula::UniformBonusCount(#mult)) } } Some(LootBonusFormula::BinomialWithBonusCount { extra, probability }) => { quote! { Some(LootBonusFormula::BinomialWithBonusCount { extra: #extra, probability: #probability }) } } } } /// Emit static entry arrays and pool literals for one table. /// Returns the list of `LootPool` literals (one per pool). fn emit_table( prefix: &str, table: &ChestLootTableJson, tokens: &mut TokenStream, ) -> Vec { let mut pool_literals = Vec::new(); for (pool_idx, pool) in table.pools.iter().enumerate() { let min_rolls = pool.rolls.min(); let max_rolls = pool.rolls.max(); let pool_cond = combine_conditions(&pool.conditions); let mut parsed_entries = Vec::new(); let mut empty_weight: i32 = 0; for entry in &pool.entries { extract_entries( entry, LootCondition::None, &mut parsed_entries, &mut empty_weight, ); } let entry_literals: Vec = parsed_entries .iter() .map(|e| { let name_lit = LitStr::new(&e.item, Span::call_site()); let weight = e.weight; let min_count = e.min_count; let max_count = e.max_count; let cond_tokens = condition_to_tokens(e.condition); let bonus_tokens = bonus_to_tokens(e.bonus_formula); quote! { LootEntry { item: #name_lit, weight: #weight, min_count: #min_count, max_count: #max_count, condition: #cond_tokens, bonus_formula: #bonus_tokens, } } }) .collect(); // Emit the entries static array. let entries_ident = format_ident!("{}_POOL{}_ENTRIES", prefix, pool_idx); tokens.extend(quote! { static #entries_ident: &[LootEntry] = &[#(#entry_literals),*]; }); let pool_cond_tokens = condition_to_tokens(pool_cond); pool_literals.push(quote! { LootPool { entries: #entries_ident, min_rolls: #min_rolls, max_rolls: #max_rolls, empty_weight: #empty_weight, condition: #pool_cond_tokens, } }); } pool_literals } /// Recursively collect all `*.json` files under `dir`, returning a vec of /// `(relative_stem_path, parsed_table)`. fn collect_json_files(base: &Path, dir: &Path) -> Vec<(String, ChestLootTableJson)> { let mut result = Vec::new(); let entries = match fs::read_dir(dir) { Ok(e) => e, Err(e) => panic!("failed to read directory {}: {e}", dir.display()), }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { result.extend(collect_json_files(base, &path)); } else if path.extension().is_some_and(|ext| ext == "json") { let relative = path .strip_prefix(base) .unwrap() .with_extension("") .to_string_lossy() .to_string(); let content = match fs::read_to_string(&path) { Ok(c) => c, Err(e) => panic!("failed to read {}: {e}", path.display()), }; let table: ChestLootTableJson = match serde_json::from_str(&content) { Ok(t) => t, Err(e) => panic!("failed to parse {}: {e}", path.display()), }; result.push((relative, table)); } } result } /// Read every loot JSON from `../../assets/datapacks/26_2/data/minecraft/loot_table/` (recursively) /// and emit a `pumpkin-data/src/generated/chest_loot.rs` with static constants /// and a `get_chest_loot_table(key) -> Option<&'static ChestLootTable>` function. pub fn build() -> TokenStream { let base = Path::new("../../assets/datapacks/26_2/data/minecraft/loot_table"); // Collect all JSON files recursively, sorted for deterministic output. let mut files: Vec<(String, ChestLootTableJson)> = collect_json_files(base, base); files.sort_by(|a, b| a.0.cmp(&b.0)); let mut all_tokens = TokenStream::new(); // Emit one set of statics per file let mut table_idents = Vec::new(); let mut table_keys = Vec::new(); let mut short_table_keys = Vec::new(); for (relative_path, table) in &files { let prefix = path_to_ident(relative_path); let key = path_to_key(relative_path); let table_ident = format_ident!("{}", prefix); let pool_tokens = emit_table(&prefix, table, &mut all_tokens); let pools_ident = format_ident!("{}_POOLS", prefix); all_tokens.extend(quote! { static #pools_ident: &[LootPool] = &[#(#pool_tokens),*]; pub static #table_ident: LootTable = LootTable { pools: #pools_ident }; }); table_idents.push(table_ident.clone()); table_keys.push(LitStr::new(&key, Span::call_site())); short_table_keys.push(LitStr::new(relative_path, Span::call_site())); } // Emit get_loot_table and get_chest_loot_table all_tokens.extend(quote! { #[must_use] pub fn get_loot_table(key: &str) -> Option<&'static LootTable> { match key { #(#table_keys | #short_table_keys => Some(&#table_idents),)* _ => None, } } #[must_use] pub fn get_chest_loot_table(key: &str) -> Option<&'static LootTable> { get_loot_table(key) } }); quote! { pub use pumpkin_util::loot_table::*; #all_tokens } }