Pumpkin/tools/pumpkin-codegen/src/villager.rs
2026-08-21 16:24:22 +02:00

528 lines
18 KiB
Rust

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<String, ProfessionJson>,
types: IndexMap<String, String>,
}
#[derive(Deserialize)]
struct ProfessionJson {
name: NameJson,
requested_items: Vec<String>,
work_sound: Option<String>,
#[serde(default)]
trade_sets: IndexMap<String, String>,
}
#[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<TradeItemJson>,
gives: TradeItemJson,
max_uses: Option<f32>,
xp: Option<f32>,
#[serde(alias = "price_multiplier")]
reputation_discount: Option<f32>,
#[serde(default)]
given_item_modifiers: Vec<Value>,
merchant_predicate: Option<Value>,
}
#[derive(Deserialize, Clone)]
struct TradeItemJson {
id: String,
count: Option<f32>,
}
fn walk_json_files<F>(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<String, TradeSetJson> = IndexMap::new();
walk_json_files(trade_set_dir, trade_set_dir, &mut |key, content| {
if let Ok(trade_set) = serde_json::from_str::<TradeSetJson>(&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<String, Vec<String>> = IndexMap::new();
walk_json_files(trade_tags_dir, trade_tags_dir, &mut |key, content| {
#[derive(Deserialize)]
struct TagFile {
values: Vec<Value>,
}
if let Ok(tag_file) = serde_json::from_str::<TagFile>(&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<String, Vec<String>>,
visited: &mut std::collections::HashSet<String>,
) -> Vec<String> {
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<String, TradeJson> = IndexMap::new();
walk_json_files(trades_dir, trades_dir, &mut |key, content| {
if let Ok(trade) = serde_json::from_str::<TradeJson>(&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::<i32>().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<VillagerTradeItem>,
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<Self> {
match id {
#(#profession_from_i32,)*
_ => None,
}
}
#[must_use]
#[allow(clippy::match_same_arms)]
pub const fn work_sound(&self) -> Option<crate::sound::Sound> {
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<VillagerTradeSet> {
match self {
#(#profession_trade_sets,)*
}
}
}
impl TryFrom<i32> for VillagerProfession {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
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<Self> {
match id {
#(#type_from_i32,)*
_ => None,
}
}
}
impl TryFrom<i32> for VillagerType {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
Self::from_i32(value).ok_or(())
}
}
}
}