Pumpkin/pumpkin-codegen/src/chest_loot.rs
Alexander Medvedev 2fcc9bcf4a chore: enforce some more clippy lints
pumpkin contributors will love me :cap:
2026-05-20 22:27:54 +02:00

282 lines
8.5 KiB
Rust

use std::{fs, path::Path};
use heck::ToShoutySnakeCase;
use proc_macro2::{Span, TokenStream};
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 EntryFunctionStruct {
function: String,
count: Option<CountStruct>,
}
/// A single entry inside a pool.
#[derive(Deserialize, Clone, Debug)]
struct PoolEntryStruct {
#[serde(rename = "type")]
entry_type: String,
/// Item name (only for `minecraft:item`).
name: Option<String>,
/// Weight (default 1 if absent).
#[serde(default = "default_weight")]
weight: i32,
/// Optional list of functions.
#[serde(default)]
functions: Vec<EntryFunctionStruct>,
}
fn default_weight() -> i32 {
1
}
#[derive(Deserialize, Clone, Debug)]
struct PoolStruct {
entries: Vec<PoolEntryStruct>,
rolls: RollsStruct,
}
/// Top-level chest loot table JSON.
#[derive(Deserialize, Clone, Debug)]
struct ChestLootTableJson {
pools: Vec<PoolStruct>,
}
/// Convert a relative path (e.g. `"trial_chambers/entrance"`) to a Minecraft
/// namespaced key (e.g. `"minecraft:chests/trial_chambers/entrance"`).
fn path_to_key(relative: &str) -> String {
format!("minecraft:chests/{relative}")
}
/// Convert a file stem path to a valid Rust SCREAMING_SNAKE_CASE identifier prefix.
/// e.g. `"trial_chambers/entrance"` -> `"TRIAL_CHAMBERS_ENTRANCE"`
fn path_to_ident(relative: &str) -> String {
relative.replace('/', "_").to_shouty_snake_case()
}
/// Read every chest loot JSON from `../assets/loot_table/chests/` (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/loot_table/chests");
// 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();
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: &[ChestLootPool] = &[#(#pool_tokens),*];
pub static #table_ident: ChestLootTable = ChestLootTable { pools: #pools_ident };
});
table_idents.push(table_ident);
table_keys.push(LitStr::new(&key, Span::call_site()));
}
// Emit get_chest_loot_table
all_tokens.extend(quote! {
#[must_use]
pub fn get_chest_loot_table(key: &str) -> Option<&'static ChestLootTable> {
match key {
#(#table_keys => Some(&#table_idents),)*
_ => None,
}
}
});
quote! {
pub use pumpkin_util::chest_loot_table::*;
#all_tokens
}
}
/// Emit static entry arrays and pool literals for one table.
/// Returns the list of `ChestLootPool` literals (one per pool).
fn emit_table(
prefix: &str,
table: &ChestLootTableJson,
tokens: &mut TokenStream,
) -> Vec<TokenStream> {
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 mut entry_literals = Vec::new();
let mut empty_weight: i32 = 0;
for entry in &pool.entries {
match entry.entry_type.as_str() {
"minecraft:empty" => {
empty_weight += entry.weight;
}
"minecraft:item" => {
let name = match &entry.name {
Some(n) => n.clone(),
None => continue, // malformed, skip
};
let weight = entry.weight;
// Extract min/max count from the first `set_count` function.
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 name_lit = LitStr::new(&name, Span::call_site());
entry_literals.push(quote! {
ChestLootEntry {
item: #name_lit,
weight: #weight,
min_count: #min_count,
max_count: #max_count,
}
});
}
_ => {} // skip loot_table refs, dynamic, etc.
}
}
// Emit the entries static array.
let entries_ident = format_ident!("{}_POOL{}_ENTRIES", prefix, pool_idx);
tokens.extend(quote! {
static #entries_ident: &[ChestLootEntry] = &[#(#entry_literals),*];
});
pool_literals.push(quote! {
ChestLootPool {
entries: #entries_ident,
min_rolls: #min_rolls,
max_rolls: #max_rolls,
empty_weight: #empty_weight,
}
});
}
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(_) => return result,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
result.extend(collect_json_files(base, &path));
} else if path.extension().and_then(|s| s.to_str()) == Some("json") {
// Build relative stem: e.g. "trial_chambers/entrance"
let rel = path.strip_prefix(base).unwrap();
let stem = rel.with_extension("");
// Use forward slashes on all platforms.
let relative = stem
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
let raw = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("Warning: could not read {}: {e}", path.display());
continue;
}
};
match serde_json::from_str::<ChestLootTableJson>(&raw) {
Ok(table) => result.push((relative, table)),
Err(e) => eprintln!("Warning: could not parse {}: {e}", path.display()),
}
}
}
result
}