From 12f5687629b768e07b865c58e7aa011db6a4fd65 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sat, 19 Sep 2026 15:38:19 +0200 Subject: [PATCH] feat: add compute command --- .../src/argument_types/resource.rs | 35 +- crates/pumpkin-util/src/loot_table.rs | 141 ++ .../src/block/entities/trial_spawner.rs | 8 +- crates/pumpkin/src/block/mod.rs | 4 +- .../pumpkin/src/command/commands/compute.rs | 1431 +++++++++++++++++ crates/pumpkin/src/command/commands/loot.rs | 119 +- crates/pumpkin/src/command/commands/mod.rs | 2 + .../data/datapack/context_provider_loader.rs | 136 ++ .../src/data/datapack/damage_type_loader.rs | 417 +++++ .../src/data/datapack/loot_table_loader.rs | 503 ++++++ crates/pumpkin/src/data/datapack/mod.rs | 542 ++++++- crates/pumpkin/src/entity/living.rs | 7 +- .../src/entity/vehicle/minecart/container.rs | 6 +- crates/pumpkin/src/item/items/brush.rs | 4 +- crates/pumpkin/src/item/items/hoe.rs | 4 +- .../src/net/java/config/known_packs.rs | 218 ++- .../pumpkin/src/net/java/login/known_packs.rs | 8 + crates/pumpkin/src/world/loot.rs | 293 +++- crates/pumpkin/src/world/mod.rs | 17 + 19 files changed, 3673 insertions(+), 222 deletions(-) create mode 100644 crates/pumpkin/src/command/commands/compute.rs create mode 100644 crates/pumpkin/src/data/datapack/context_provider_loader.rs create mode 100644 crates/pumpkin/src/data/datapack/damage_type_loader.rs create mode 100644 crates/pumpkin/src/data/datapack/loot_table_loader.rs diff --git a/crates/pumpkin-command/src/argument_types/resource.rs b/crates/pumpkin-command/src/argument_types/resource.rs index 4d5efc3cc..5172be145 100644 --- a/crates/pumpkin-command/src/argument_types/resource.rs +++ b/crates/pumpkin-command/src/argument_types/resource.rs @@ -107,8 +107,35 @@ pub static MOB_EFFECT_ARGUMENT: ResourceArgument = .map(|value| value as &'static (dyn Any + Send + Sync)) }); +pub type DynamicDamageTypeResolver = + Box Option<&'static pumpkin_data::damage::DamageType> + Send + Sync>; + +pub static DYNAMIC_DAMAGE_TYPE_RESOLVER: std::sync::RwLock> = + std::sync::RwLock::new(None); +pub static DYNAMIC_DAMAGE_TYPE_NAMES: std::sync::RwLock> = + std::sync::RwLock::new(Vec::new()); + +pub fn register_dynamic_damage_types(resolver: DynamicDamageTypeResolver, names: Vec) { + if let Ok(mut guard) = DYNAMIC_DAMAGE_TYPE_RESOLVER.write() { + *guard = Some(resolver); + } + if let Ok(mut guard) = DYNAMIC_DAMAGE_TYPE_NAMES.write() { + *guard = names; + } +} + pub static DAMAGE_TYPE_ARGUMENT: ResourceArgument = ResourceArgument(DAMAGE_TYPE_REGISTRY, &|id: Identifier| { + if let Ok(guard) = DYNAMIC_DAMAGE_TYPE_RESOLVER.read() + && let Some(resolver) = guard.as_ref() + { + let id_str = id.to_string(); + let path_str = id.path(); + if let Some(dt) = resolver(&id_str).or_else(|| resolver(path_str)) { + return Some(dt as &'static (dyn Any + Send + Sync)); + } + } + let dt = pumpkin_data::damage::DamageType::from_name(id.path()) .or_else(|| pumpkin_data::damage::DamageType::from_name(&id.to_string()))?; let idx = dt.id as usize; @@ -207,7 +234,13 @@ impl ArgumentType for ResourceArgument { let types = DAMAGE_TYPES .iter() .map(|dt| format!("minecraft:{}", dt.message_id)); - suggestions_builder.filter_and_suggest_iter(types).build() + let extra = DYNAMIC_DAMAGE_TYPE_NAMES + .read() + .map(|g| g.clone()) + .unwrap_or_default(); + suggestions_builder + .filter_and_suggest_iter(types.chain(extra)) + .build() } else { Suggestions::empty() } diff --git a/crates/pumpkin-util/src/loot_table.rs b/crates/pumpkin-util/src/loot_table.rs index becdfde7e..698934aba 100644 --- a/crates/pumpkin-util/src/loot_table.rs +++ b/crates/pumpkin-util/src/loot_table.rs @@ -75,3 +75,144 @@ pub struct LootTable { pub type ChestLootEntry = LootEntry; pub type ChestLootPool = LootPool; pub type ChestLootTable = LootTable; + +/// Conditions required for an entry or pool to be eligible for dynamic loot generation. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum DynamicLootCondition { + #[default] + None, + SilkTouch, + NoSilkTouch, + Shears, + SilkTouchOrShears, + NoSilkTouchOrShears, + SurvivesExplosion, + KilledByPlayer, + RandomChance { + chance: f32, + }, + RandomChanceWithEnchantedBonus { + unenchanted_chance: f32, + enchanted_chance_base: f32, + enchanted_chance_per_level_above_first: f32, + }, + TableBonus { + chances: Box<[f32]>, + }, + AllOf(Vec), + AnyOf(Vec), + Inverted(Box), + EntityOnFire, + WeatherCheck { + raining: Option, + thundering: Option, + }, +} + +impl From for DynamicLootCondition { + fn from(cond: LootCondition) -> Self { + match cond { + LootCondition::None => Self::None, + LootCondition::SilkTouch => Self::SilkTouch, + LootCondition::NoSilkTouch => Self::NoSilkTouch, + LootCondition::Shears => Self::Shears, + LootCondition::SilkTouchOrShears => Self::SilkTouchOrShears, + LootCondition::NoSilkTouchOrShears => Self::NoSilkTouchOrShears, + LootCondition::SurvivesExplosion => Self::SurvivesExplosion, + LootCondition::KilledByPlayer => Self::KilledByPlayer, + LootCondition::RandomChance { chance } => Self::RandomChance { chance }, + LootCondition::RandomChanceWithEnchantedBonus { + unenchanted_chance, + enchanted_chance_base, + enchanted_chance_per_level_above_first, + } => Self::RandomChanceWithEnchantedBonus { + unenchanted_chance, + enchanted_chance_base, + enchanted_chance_per_level_above_first, + }, + LootCondition::TableBonus { chances } => Self::TableBonus { + chances: chances.to_vec().into_boxed_slice(), + }, + LootCondition::AllOf(conditions) => { + Self::AllOf(conditions.iter().copied().map(Self::from).collect()) + } + } + } +} + +/// A single item entry inside a dynamic loot pool. +#[derive(Clone, Debug)] +pub struct DynamicLootEntry { + /// Registry name of the item (e.g. `"minecraft:diamond"`). + pub item: String, + /// Relative probability weight; higher values are more likely. + pub weight: i32, + /// Minimum stack size (inclusive). + pub min_count: i32, + /// Maximum stack size (inclusive). + pub max_count: i32, + /// Condition required for this entry to be eligible. + pub condition: DynamicLootCondition, + /// Bonus formula to apply with fortune / looting (if any). + pub bonus_formula: Option, +} + +impl From for DynamicLootEntry { + fn from(e: LootEntry) -> Self { + Self { + item: e.item.to_string(), + weight: e.weight, + min_count: e.min_count, + max_count: e.max_count, + condition: DynamicLootCondition::from(e.condition), + bonus_formula: e.bonus_formula, + } + } +} + +/// One roll pool inside a dynamic loot table. +#[derive(Clone, Debug)] +pub struct DynamicLootPool { + /// Item entries eligible for selection each roll. + pub entries: Vec, + /// Minimum number of roll attempts (inclusive). + pub min_rolls: i32, + /// Maximum number of roll attempts (inclusive). + pub max_rolls: i32, + /// Weight of the implicit "empty" (no item) outcome per roll. + pub empty_weight: i32, + /// Condition required for this entire pool to run. + pub condition: DynamicLootCondition, +} + +impl From for DynamicLootPool { + fn from(p: LootPool) -> Self { + Self { + entries: p + .entries + .iter() + .copied() + .map(DynamicLootEntry::from) + .collect(), + min_rolls: p.min_rolls, + max_rolls: p.max_rolls, + empty_weight: p.empty_weight, + condition: DynamicLootCondition::from(p.condition), + } + } +} + +/// A complete dynamic loot table consisting of one or more pools. +#[derive(Clone, Debug, Default)] +pub struct DynamicLootTable { + /// All pools to roll when generating loot for this table. + pub pools: Vec, +} + +impl From<&LootTable> for DynamicLootTable { + fn from(t: &LootTable) -> Self { + Self { + pools: t.pools.iter().copied().map(DynamicLootPool::from).collect(), + } + } +} diff --git a/crates/pumpkin/src/block/entities/trial_spawner.rs b/crates/pumpkin/src/block/entities/trial_spawner.rs index 7a8ebb723..46cebce16 100644 --- a/crates/pumpkin/src/block/entities/trial_spawner.rs +++ b/crates/pumpkin/src/block/entities/trial_spawner.rs @@ -1055,11 +1055,11 @@ impl TrialSpawner { } if let Some(equip) = equipment_loot_table - && let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(&equip) + && let Some(loot_table) = world.get_loot_table(&equip) && let Some(living) = entity.get_living_entity() { let seed = rand::random::(); - let items = crate::world::loot::generate_loot(loot_table, seed); + let items = loot_table.generate_loot(seed); let mut equipment = living .entity_equipment .lock() @@ -1126,10 +1126,10 @@ impl TrialSpawner { ) { let mut dropped_any = false; if let Some(key) = ejecting_loot_table - && let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(key) + && let Some(loot_table) = world.get_loot_table(key) { let seed = rand::random::(); - let items = crate::world::loot::generate_loot(loot_table, seed); + let items = loot_table.generate_loot(seed); for stack in items { world.drop_stack(&spawner_pos, stack); dropped_any = true; diff --git a/crates/pumpkin/src/block/mod.rs b/crates/pumpkin/src/block/mod.rs index f7021252e..4fc742571 100644 --- a/crates/pumpkin/src/block/mod.rs +++ b/crates/pumpkin/src/block/mod.rs @@ -460,9 +460,9 @@ pub fn drop_loot( params: &LootContextParameters, ) { let key = format!("minecraft:blocks/{}", block.name); - if let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(&key) { + if let Some(loot_table) = world.get_loot_table(&key) { let seed: i64 = rand::random(); - let items = crate::world::loot::generate_loot_with_context(loot_table, seed, params); + let items = crate::world::loot::generate_loot_from_handle(&loot_table, seed, params); if !items.is_empty() { let mut event = crate::plugin::block::block_drop_item::BlockDropItemEvent { block_pos: *pos, diff --git a/crates/pumpkin/src/command/commands/compute.rs b/crates/pumpkin/src/command/commands/compute.rs new file mode 100644 index 000000000..10ad44a09 --- /dev/null +++ b/crates/pumpkin/src/command/commands/compute.rs @@ -0,0 +1,1431 @@ +use std::cell::Cell; +use std::sync::Arc; + +use pumpkin_data::translation; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use pumpkin_util::PermissionLvl; +use pumpkin_util::identifier::Identifier; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::TextComponent; +use rand::RngExt; + +use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; +use crate::command::argument_types::FromStringReader; +use crate::command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; +use crate::command::argument_types::coordinates::block_pos::BlockPosArgumentType; +use crate::command::argument_types::core::double::DoubleArgumentType; +use crate::command::argument_types::entity::EntityArgumentType; +use crate::command::context::command_context::CommandContext; +use crate::command::context::command_source::CommandSource; +use crate::command::errors::command_syntax_error::CommandSyntaxError; +use crate::command::errors::error_types::{CommandErrorType, LiteralCommandErrorType}; +use crate::command::node::dispatcher::CommandDispatcher; +use crate::command::node::{CommandExecutor, CommandExecutorResult}; +use crate::command::snbt::SnbtParser; +use crate::command::string_reader::StringReader; +use crate::command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; +use crate::command::suggestion::suggestions::SuggestionsBuilder; +use crate::data::datapack::DatapackManager; +use crate::entity::EntityBase; + +const DESCRIPTION: &str = "Evaluates number providers."; +const PERMISSION: &str = "minecraft:command.compute"; + +const ARG_POS: &str = "pos"; +const ARG_TARGET: &str = "target"; +const ARG_INT_PROVIDER: &str = "int_provider"; +const ARG_FLOAT_PROVIDER: &str = "float_provider"; +const ARG_SCALE: &str = "scale"; + +const MAX_PROVIDER_DEPTH: usize = 64; + +const ERROR_DIV_BY_ZERO: LiteralCommandErrorType = LiteralCommandErrorType::new("Division by zero"); +const ERROR_POW_ZERO_ZERO: LiteralCommandErrorType = + LiteralCommandErrorType::new("0^0 is undefined"); +const ERROR_EVALUATION_FAILED: LiteralCommandErrorType = + LiteralCommandErrorType::new("Failed to evaluate number provider"); + +#[allow(dead_code)] +pub const ERROR_BLOCK_INVALID: CommandErrorType<0> = CommandErrorType::new( + translation::java::COMMANDS_DATA_BLOCK_INVALID, + translation::java::COMMANDS_DATA_BLOCK_INVALID, +); + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TargetType { + Default, + Block, + Entity, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ProviderArg { + Number(f64), + Nbt(NbtCompound), + Id(Identifier), +} + +pub struct ProviderArgumentType; + +impl ArgumentType for ProviderArgumentType { + type Item = ProviderArg; + + fn parse(&self, reader: &mut StringReader) -> Result { + reader.skip_whitespace(); + if reader.peek() == Some('{') { + let tag = SnbtParser::parse_for_commands(reader)?; + if let NbtTag::Compound(compound) = tag { + return Ok(ProviderArg::Nbt(compound)); + } + } + + let start = reader.cursor(); + if let Ok(num) = reader.read_double() { + let is_word = reader + .peek() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == ':'); + if !is_word { + return Ok(ProviderArg::Number(num)); + } + } + reader.set_cursor(start); + + let id = Identifier::from_reader(reader)?; + Ok(ProviderArg::Id(id)) + } + + fn client_side_parser(&self) -> JavaClientArgumentType { + JavaClientArgumentType::NbtTag + } + + fn examples(&self) -> Vec { + vec![ + "5".to_string(), + "3.14".to_string(), + "minecraft:constant".to_string(), + "{type:\"constant\",value:5}".to_string(), + "{type:\"add\",left:1,right:2}".to_string(), + ] + } +} + +impl ProviderArgumentType { + pub fn get<'a>( + context: &'a CommandContext, + name: &str, + ) -> Result<&'a ProviderArg, CommandSyntaxError> { + context.get_argument(name) + } +} + +pub struct ComputeContext<'a> { + pub source: &'a CommandSource, + #[allow(dead_code)] + pub target_type: TargetType, + #[allow(dead_code)] + pub block_pos: Option, + pub target_entity: Option>, + pub datapack_manager: Option<&'a DatapackManager>, + pub depth: Cell, +} + +fn build_context<'a>( + context: &'a CommandContext, + target_type: TargetType, +) -> Result, CommandSyntaxError> { + let datapack_manager = context + .source + .server + .as_ref() + .map(|s| s.datapack_manager.as_ref()); + + match target_type { + TargetType::Default => Ok(ComputeContext { + source: context.source.as_ref(), + target_type: TargetType::Default, + block_pos: None, + target_entity: None, + datapack_manager, + depth: Cell::new(0), + }), + TargetType::Block => { + let pos = BlockPosArgumentType::get_block_pos(context, ARG_POS)?; + Ok(ComputeContext { + source: context.source.as_ref(), + target_type: TargetType::Block, + block_pos: Some(pos), + target_entity: None, + datapack_manager, + depth: Cell::new(0), + }) + } + TargetType::Entity => { + let entity = EntityArgumentType::get_entity(context, ARG_TARGET)?; + Ok(ComputeContext { + source: context.source.as_ref(), + target_type: TargetType::Entity, + block_pos: None, + target_entity: Some(entity), + datapack_manager, + depth: Cell::new(0), + }) + } + } +} + +fn resolve_target_name(target_tag: &NbtTag, ctx: &ComputeContext) -> Option { + match target_tag { + NbtTag::String(s) => Some(s.to_string()), + NbtTag::Compound(c) => { + let t = c.get_string("target")?; + match t { + "target_entity" => ctx.target_entity.as_ref().map(|e| e.get_scoreboard_name()), + "this" => ctx.source.entity.as_ref().map(|e| e.get_scoreboard_name()), + _ => None, + } + } + _ => None, + } +} + +fn evaluate_score_provider_int( + compound: &NbtCompound, + ctx: &ComputeContext, +) -> Result { + let score_name = compound + .get_string("score") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?; + let target_name = compound + .get("target") + .and_then(|t| resolve_target_name(t, ctx)); + + if let (Some(target), Some(world)) = (target_name, ctx.source.world.as_ref()) { + let scoreboard = world.scoreboard.lock().unwrap(); + if let Some(score) = scoreboard.get_score(&target, score_name) { + return Ok(i64::from(score.value.0)); + } + } + + compound + .get("fallback") + .map_or(Ok(0), |fallback| evaluate_tag_int(fallback, ctx)) +} + +fn evaluate_score_provider_float( + compound: &NbtCompound, + ctx: &ComputeContext, +) -> Result { + let score_name = compound + .get_string("score") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?; + let target_name = compound + .get("target") + .and_then(|t| resolve_target_name(t, ctx)); + + if let (Some(target), Some(world)) = (target_name, ctx.source.world.as_ref()) { + let scoreboard = world.scoreboard.lock().unwrap(); + if let Some(score) = scoreboard.get_score(&target, score_name) { + return Ok(f64::from(score.value.0)); + } + } + + compound + .get("fallback") + .map_or(Ok(0.0), |fallback| evaluate_tag_float(fallback, ctx)) +} + +fn resolve_int_provider_id(id: &str, ctx: &ComputeContext) -> Result { + if ctx.depth.get() >= MAX_PROVIDER_DEPTH { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + + let tag = ctx + .datapack_manager + .and_then(|dm| { + dm.get_context_int_provider(id).or_else(|| { + if id.contains(':') { + None + } else { + dm.get_context_int_provider(&format!("minecraft:{id}")) + } + }) + }) + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?; + + ctx.depth.set(ctx.depth.get() + 1); + let res = evaluate_tag_int(&tag, ctx); + ctx.depth.set(ctx.depth.get() - 1); + res +} + +fn evaluate_tag_int(tag: &NbtTag, ctx: &ComputeContext) -> Result { + match tag { + NbtTag::Byte(b) => Ok(i64::from(*b)), + NbtTag::Short(s) => Ok(i64::from(*s)), + NbtTag::Int(i) => Ok(i64::from(*i)), + NbtTag::Long(l) => Ok(*l), + #[allow(clippy::cast_possible_truncation)] + NbtTag::Float(f) => Ok(*f as i64), + #[allow(clippy::cast_possible_truncation)] + NbtTag::Double(d) => Ok(*d as i64), + NbtTag::String(s) => s + .parse::() + .map_or_else(|_| resolve_int_provider_id(s, ctx), Ok), + NbtTag::Compound(compound) => evaluate_compound_int(compound, ctx), + _ => Err(ERROR_EVALUATION_FAILED.create_without_context()), + } +} + +#[allow(clippy::too_many_lines, clippy::match_same_arms)] +fn evaluate_compound_int( + compound: &NbtCompound, + ctx: &ComputeContext, +) -> Result { + let op_type = compound + .get_string("type") + .map(|s| s.strip_prefix("minecraft:").unwrap_or(s)); + + match op_type { + Some("constant") => compound.get("value").map_or_else( + || Err(ERROR_EVALUATION_FAILED.create_without_context()), + |val_tag| evaluate_tag_int(val_tag, ctx), + ), + Some("add" | "sum") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + let mut sum = 0i64; + for tag in inputs { + sum = sum.wrapping_add(evaluate_tag_int(tag, ctx)?); + } + Ok(sum) + } else { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.wrapping_add(right)) + } + } + Some("sub") => { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.wrapping_sub(right)) + } + Some("mul" | "product") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + let mut prod = 1i64; + for tag in inputs { + prod = prod.wrapping_mul(evaluate_tag_int(tag, ctx)?); + } + Ok(prod) + } else { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.wrapping_mul(right)) + } + } + Some("div") => { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(left / right) + } + Some("floor_div") => { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + #[allow(clippy::cast_possible_truncation)] + Ok(((left as f64) / (right as f64)).floor() as i64) + } + Some("floor_mod") => { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(((left % right) + right) % right) + } + Some("mod") => { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(left % right) + } + Some("pow") => { + let base = evaluate_tag_int( + compound + .get("base") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let exponent = evaluate_tag_int( + compound + .get("exponent") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if base == 0 && exponent == 0 { + return Err(ERROR_POW_ZERO_ZERO.create_without_context()); + } + if exponent < 0 { + return Ok(0); + } + #[allow(clippy::cast_sign_loss)] + Ok(base.wrapping_pow(exponent as u32)) + } + Some("abs") => { + let input = evaluate_tag_int( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.abs()) + } + Some("negate") => { + let input = evaluate_tag_int( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(-input) + } + Some("min") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut min_val = i64::MAX; + for tag in inputs { + min_val = min_val.min(evaluate_tag_int(tag, ctx)?); + } + Ok(min_val) + } else { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.min(right)) + } + } + Some("max") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut max_val = i64::MIN; + for tag in inputs { + max_val = max_val.max(evaluate_tag_int(tag, ctx)?); + } + Ok(max_val) + } else { + let left = evaluate_tag_int( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_int( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.max(right)) + } + } + Some("avg" | "average") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut sum = 0i64; + for tag in inputs { + sum = sum.wrapping_add(evaluate_tag_int(tag, ctx)?); + } + #[allow(clippy::cast_possible_wrap)] + Ok(sum / (inputs.len() as i64)) + } else { + Err(ERROR_EVALUATION_FAILED.create_without_context()) + } + } + Some("uniform") => { + let min = evaluate_tag_int( + compound + .get("min") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let max = evaluate_tag_int( + compound + .get("max") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if min > max { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + Ok(rand::rng().random_range(min..=max)) + } + Some("score") => evaluate_score_provider_int(compound, ctx), + _ => compound.get("value").map_or_else( + || Err(ERROR_EVALUATION_FAILED.create_without_context()), + |val_tag| evaluate_tag_int(val_tag, ctx), + ), + } +} + +fn resolve_float_provider_id(id: &str, ctx: &ComputeContext) -> Result { + if ctx.depth.get() >= MAX_PROVIDER_DEPTH { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + + let tag = ctx + .datapack_manager + .and_then(|dm| { + dm.get_context_float_provider(id).or_else(|| { + if id.contains(':') { + None + } else { + dm.get_context_float_provider(&format!("minecraft:{id}")) + } + }) + }) + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?; + + ctx.depth.set(ctx.depth.get() + 1); + let res = evaluate_tag_float(&tag, ctx); + ctx.depth.set(ctx.depth.get() - 1); + res +} + +fn evaluate_tag_float(tag: &NbtTag, ctx: &ComputeContext) -> Result { + match tag { + NbtTag::Byte(b) => Ok(f64::from(*b)), + NbtTag::Short(s) => Ok(f64::from(*s)), + NbtTag::Int(i) => Ok(f64::from(*i)), + NbtTag::Long(l) => + { + #[allow(clippy::cast_precision_loss)] + Ok(*l as f64) + } + NbtTag::Float(f) => Ok(f64::from(*f)), + NbtTag::Double(d) => Ok(*d), + NbtTag::String(s) => s + .parse::() + .map_or_else(|_| resolve_float_provider_id(s, ctx), Ok), + NbtTag::Compound(compound) => evaluate_compound_float(compound, ctx), + _ => Err(ERROR_EVALUATION_FAILED.create_without_context()), + } +} + +#[allow(clippy::too_many_lines, clippy::match_same_arms)] +fn evaluate_compound_float( + compound: &NbtCompound, + ctx: &ComputeContext, +) -> Result { + let op_type = compound + .get_string("type") + .map(|s| s.strip_prefix("minecraft:").unwrap_or(s)); + + match op_type { + Some("constant") => compound.get("value").map_or_else( + || Err(ERROR_EVALUATION_FAILED.create_without_context()), + |val_tag| evaluate_tag_float(val_tag, ctx), + ), + Some("add" | "sum") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + let mut sum = 0.0; + for tag in inputs { + sum += evaluate_tag_float(tag, ctx)?; + } + Ok(sum) + } else { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left + right) + } + } + Some("sub") => { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left - right) + } + Some("mul" | "product") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + let mut prod = 1.0; + for tag in inputs { + prod *= evaluate_tag_float(tag, ctx)?; + } + Ok(prod) + } else { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left * right) + } + } + Some("div") => { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0.0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(left / right) + } + Some("floor_div") => { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0.0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok((left / right).floor()) + } + Some("floor_mod") => { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0.0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(((left % right) + right) % right) + } + Some("mod") => { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if right == 0.0 { + return Err(ERROR_DIV_BY_ZERO.create_without_context()); + } + Ok(left % right) + } + Some("pow") => { + let base = evaluate_tag_float( + compound + .get("base") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let exponent = evaluate_tag_float( + compound + .get("exponent") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if base == 0.0 && exponent == 0.0 { + return Err(ERROR_POW_ZERO_ZERO.create_without_context()); + } + Ok(base.powf(exponent)) + } + Some("abs") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.abs()) + } + Some("negate") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(-input) + } + Some("min") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut min_val = f64::INFINITY; + for tag in inputs { + min_val = min_val.min(evaluate_tag_float(tag, ctx)?); + } + Ok(min_val) + } else { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.min(right)) + } + } + Some("max") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut max_val = f64::NEG_INFINITY; + for tag in inputs { + max_val = max_val.max(evaluate_tag_float(tag, ctx)?); + } + Ok(max_val) + } else { + let left = evaluate_tag_float( + compound + .get("left") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let right = evaluate_tag_float( + compound + .get("right") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(left.max(right)) + } + } + Some("avg" | "average") => { + if let Some(NbtTag::List(inputs)) = compound.get("inputs") { + if inputs.is_empty() { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + let mut sum = 0.0; + for tag in inputs { + sum += evaluate_tag_float(tag, ctx)?; + } + #[allow(clippy::cast_precision_loss)] + Ok(sum / (inputs.len() as f64)) + } else { + Err(ERROR_EVALUATION_FAILED.create_without_context()) + } + } + Some("uniform") => { + let min = evaluate_tag_float( + compound + .get("min") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + let max = evaluate_tag_float( + compound + .get("max") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if min > max { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + Ok(rand::rng().random_range(min..=max)) + } + Some("sqrt") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + if input < 0.0 { + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + Ok(input.sqrt()) + } + Some("sin") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.sin()) + } + Some("cos") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.cos()) + } + Some("ceil") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.ceil()) + } + Some("floor") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.floor()) + } + Some("round") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.round()) + } + Some("truncate") => { + let input = evaluate_tag_float( + compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?, + ctx, + )?; + Ok(input.trunc()) + } + Some("from_int") => { + let input = compound + .get("input") + .ok_or_else(|| ERROR_EVALUATION_FAILED.create_without_context())?; + #[allow(clippy::cast_precision_loss)] + Ok(evaluate_tag_int(input, ctx)? as f64) + } + Some("score") => evaluate_score_provider_float(compound, ctx), + _ => compound.get("value").map_or_else( + || Err(ERROR_EVALUATION_FAILED.create_without_context()), + |val_tag| evaluate_tag_float(val_tag, ctx), + ), + } +} + +fn evaluate_int( + provider: &ProviderArg, + ctx: &ComputeContext, +) -> Result<(i64, Option), CommandSyntaxError> { + match provider { + #[allow(clippy::cast_possible_truncation)] + ProviderArg::Number(n) => Ok((*n as i64, None)), + ProviderArg::Nbt(compound) => { + let val = evaluate_compound_int(compound, ctx)?; + Ok((val, None)) + } + ProviderArg::Id(id) => { + let id_str = id.to_string(); + let val = resolve_int_provider_id(&id_str, ctx)?; + Ok((val, Some(id_str))) + } + } +} + +fn evaluate_float( + provider: &ProviderArg, + ctx: &ComputeContext, +) -> Result<(f64, Option), CommandSyntaxError> { + match provider { + ProviderArg::Number(n) => Ok((*n, None)), + ProviderArg::Nbt(compound) => { + let val = evaluate_compound_float(compound, ctx)?; + Ok((val, None)) + } + ProviderArg::Id(id) => { + let id_str = id.to_string(); + let val = resolve_float_provider_id(&id_str, ctx)?; + Ok((val, Some(id_str))) + } + } +} + +struct ComputeIntegerExecutor { + target_type: TargetType, +} + +impl CommandExecutor for ComputeIntegerExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + let provider = ProviderArgumentType::get(context, ARG_INT_PROVIDER)?; + let ctx = build_context(context, self.target_type)?; + + let (value, name) = evaluate_int(provider, &ctx)?; + #[allow(clippy::cast_possible_truncation)] + let result = value as i32; + + let msg = name.map_or_else( + || { + TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_EXACT, + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_EXACT, + [TextComponent::text(result.to_string())], + ) + }, + |name| { + TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_NAMED_EXACT, + translation::java::COMMAND_COMPUTE_RESULT_NAMED_EXACT, + [ + TextComponent::text(name), + TextComponent::text(result.to_string()), + ], + ) + }, + ); + + context.source.send_feedback(msg, false); + Ok(result) + } +} + +struct ComputeFloatExecutor { + target_type: TargetType, + has_scale: bool, +} + +impl CommandExecutor for ComputeFloatExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + let provider = ProviderArgumentType::get(context, ARG_FLOAT_PROVIDER)?; + let scale = if self.has_scale { + DoubleArgumentType::get(context, ARG_SCALE)? + } else { + 1.0 + }; + + let ctx = build_context(context, self.target_type)?; + let (raw_value, name) = evaluate_float(provider, &ctx)?; + + if raw_value.is_nan() || raw_value.is_infinite() { + let msg = name.as_deref().map_or_else( + || { + TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_INVALID, + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_INVALID, + [TextComponent::text(raw_value.to_string())], + ) + }, + |name| { + TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_NAMED_INVALID, + translation::java::COMMAND_COMPUTE_RESULT_NAMED_INVALID, + [ + TextComponent::text(name.to_string()), + TextComponent::text(raw_value.to_string()), + ], + ) + }, + ); + context.source.send_feedback(msg, false); + return Err(ERROR_EVALUATION_FAILED.create_without_context()); + } + + let scaled = raw_value * scale; + #[allow(clippy::cast_possible_truncation)] + let rounded = scaled.floor() as i32; + let is_exact = scaled == f64::from(rounded); + + let msg = match (name, is_exact) { + (Some(name), true) => TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_NAMED_EXACT, + translation::java::COMMAND_COMPUTE_RESULT_NAMED_EXACT, + [ + TextComponent::text(name), + TextComponent::text(scaled.to_string()), + ], + ), + (Some(name), false) => TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_NAMED_ROUNDED, + translation::java::COMMAND_COMPUTE_RESULT_NAMED_ROUNDED, + [ + TextComponent::text(name), + TextComponent::text(scaled.to_string()), + TextComponent::text(rounded.to_string()), + ], + ), + (None, true) => TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_EXACT, + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_EXACT, + [TextComponent::text(scaled.to_string())], + ), + (None, false) => TextComponent::translate_cross( + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_ROUNDED, + translation::java::COMMAND_COMPUTE_RESULT_UNNAMED_ROUNDED, + [ + TextComponent::text(scaled.to_string()), + TextComponent::text(rounded.to_string()), + ], + ), + }; + + context.source.send_feedback(msg, false); + Ok(rounded) + } +} + +struct ContextIntProviderSuggestionProvider; + +impl SuggestionProvider for ContextIntProviderSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + if let Some(server) = context.source.server.as_ref() { + for name in server.datapack_manager.get_context_int_provider_names() { + builder = builder.suggest(name); + } + } + builder.build() + } +} + +struct ContextFloatProviderSuggestionProvider; + +impl SuggestionProvider for ContextFloatProviderSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + if let Some(server) = context.source.server.as_ref() { + for name in server.datapack_manager.get_context_float_provider_names() { + builder = builder.suggest(name); + } + } + builder.build() + } +} + +fn add_provider_nodes(builder: B, target_type: TargetType) -> B { + builder + .then( + literal("integer").then( + argument(ARG_INT_PROVIDER, ProviderArgumentType) + .suggests(ContextIntProviderSuggestionProvider) + .executes(ComputeIntegerExecutor { target_type }), + ), + ) + .then( + literal("float").then( + argument(ARG_FLOAT_PROVIDER, ProviderArgumentType) + .suggests(ContextFloatProviderSuggestionProvider) + .executes(ComputeFloatExecutor { + target_type, + has_scale: false, + }) + .then(argument(ARG_SCALE, DoubleArgumentType::any()).executes( + ComputeFloatExecutor { + target_type, + has_scale: true, + }, + )), + ), + ) +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + let default_node = add_provider_nodes(literal("default"), TargetType::Default); + let block_arg = add_provider_nodes(argument(ARG_POS, BlockPosArgumentType), TargetType::Block); + let block_node = literal("block").then(block_arg); + let entity_arg = add_provider_nodes( + argument(ARG_TARGET, EntityArgumentType::Entity), + TargetType::Entity, + ); + let entity_node = literal("entity").then(entity_arg); + + dispatcher.register( + command("compute", DESCRIPTION) + .requires(PERMISSION) + .then(default_node) + .then(block_node) + .then(entity_node), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn parse_compute() { + let mut dispatcher = CommandDispatcher::new(); + let registry = PermissionRegistry::default(); + register(&mut dispatcher, ®istry); + + let source = Arc::new(CommandSource::dummy()); + + let res = dispatcher.parse_input("compute default integer 42", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute default float 3.14", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute default float 3.14 2.0", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute block ~ ~ ~ integer 42", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute block ~ ~ ~ float 3.14", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute block ~ ~ ~ float 3.14 2.0", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute entity @s integer 42", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute entity @s float 3.14", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input("compute entity @s float 3.14 2.0", &source); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input( + "compute default integer {type:\"constant\",value:5}", + &source, + ); + assert!(res.errors.is_empty(), "{:?}", res.errors); + + let res = dispatcher.parse_input( + "compute default float {type:\"add\",left:1.5,right:2.5} 1.0", + &source, + ); + assert!(res.errors.is_empty(), "{:?}", res.errors); + } + + fn parse_compound(snbt: &str) -> NbtCompound { + let tag = SnbtParser::parse_for_commands(&mut StringReader::new(snbt)).unwrap(); + match tag { + NbtTag::Compound(c) => c, + _ => panic!("Expected compound"), + } + } + + #[test] + fn evaluate_int_works() { + let source = CommandSource::dummy(); + let ctx = ComputeContext { + source: &source, + target_type: TargetType::Default, + block_pos: None, + target_entity: None, + datapack_manager: None, + depth: Cell::new(0), + }; + + let c = parse_compound("{type:\"constant\",value:42}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 42); + + let c = parse_compound("{type:\"add\",left:10,right:32}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 42); + + let c = parse_compound("{type:\"add\",inputs:[1,2,3,4]}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 10); + + let c = parse_compound("{type:\"sub\",left:50,right:8}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 42); + + let c = parse_compound("{type:\"mul\",left:6,right:7}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 42); + + let c = parse_compound("{type:\"mul\",inputs:[2,3,4]}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 24); + + let c = parse_compound("{type:\"div\",left:84,right:2}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 42); + + let c = parse_compound("{type:\"div\",left:84,right:0}"); + assert!(evaluate_compound_int(&c, &ctx).is_err()); + + let c = parse_compound("{type:\"floor_div\",left:7,right:2}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 3); + + let c = parse_compound("{type:\"floor_mod\",left:-1,right:5}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 4); + + let c = parse_compound("{type:\"mod\",left:17,right:5}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 2); + + let c = parse_compound("{type:\"pow\",base:2,exponent:3}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 8); + + let c = parse_compound("{type:\"pow\",base:0,exponent:0}"); + assert!(evaluate_compound_int(&c, &ctx).is_err()); + + let c = parse_compound("{type:\"abs\",input:-15}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 15); + + let c = parse_compound("{type:\"negate\",input:42}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), -42); + + let c = parse_compound("{type:\"min\",inputs:[10,5,20]}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 5); + + let c = parse_compound("{type:\"max\",inputs:[10,5,20]}"); + assert_eq!(evaluate_compound_int(&c, &ctx).unwrap(), 20); + } + + #[test] + fn evaluate_float_works() { + let source = CommandSource::dummy(); + let ctx = ComputeContext { + source: &source, + target_type: TargetType::Default, + block_pos: None, + target_entity: None, + datapack_manager: None, + depth: Cell::new(0), + }; + + let c = parse_compound("{type:\"constant\",value:3.5}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 3.5).abs() < 1e-6); + + let c = parse_compound("{type:\"add\",left:1.5,right:2.0}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 3.5).abs() < 1e-6); + + let c = parse_compound("{type:\"sub\",left:5.5,right:2.0}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 3.5).abs() < 1e-6); + + let c = parse_compound("{type:\"mul\",left:2.0,right:3.5}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 7.0).abs() < 1e-6); + + let c = parse_compound("{type:\"div\",left:7.0,right:2.0}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 3.5).abs() < 1e-6); + + let c = parse_compound("{type:\"div\",left:7.0,right:0.0}"); + assert!(evaluate_compound_float(&c, &ctx).is_err()); + + let c = parse_compound("{type:\"pow\",base:0.0,exponent:0.0}"); + assert!(evaluate_compound_float(&c, &ctx).is_err()); + + let c = parse_compound("{type:\"sqrt\",input:16.0}"); + assert!((evaluate_compound_float(&c, &ctx).unwrap() - 4.0).abs() < 1e-6); + + let c = parse_compound("{type:\"ceil\",input:3.2}"); + assert_eq!(evaluate_compound_float(&c, &ctx).unwrap(), 4.0); + + let c = parse_compound("{type:\"floor\",input:3.8}"); + assert_eq!(evaluate_compound_float(&c, &ctx).unwrap(), 3.0); + + let c = parse_compound("{type:\"round\",input:3.5}"); + assert_eq!(evaluate_compound_float(&c, &ctx).unwrap(), 4.0); + + let c = parse_compound("{type:\"truncate\",input:3.8}"); + assert_eq!(evaluate_compound_float(&c, &ctx).unwrap(), 3.0); + + let c = parse_compound("{type:\"from_int\",input:42}"); + assert_eq!(evaluate_compound_float(&c, &ctx).unwrap(), 42.0); + } + + #[test] + fn datapack_providers() { + let dm = DatapackManager::new(); + dm.insert_context_int_provider( + "my_pack:calc_base".to_string(), + NbtTag::Compound(parse_compound("{type:\"constant\",value:20}")), + ); + dm.insert_context_int_provider( + "my_pack:calc_add".to_string(), + NbtTag::Compound(parse_compound( + "{type:\"add\",left:\"my_pack:calc_base\",right:22}", + )), + ); + dm.insert_context_float_provider( + "my_pack:float_calc".to_string(), + NbtTag::Compound(parse_compound("{type:\"add\",left:2.5,right:3.5}")), + ); + + let source = CommandSource::dummy(); + let ctx = ComputeContext { + source: &source, + target_type: TargetType::Default, + block_pos: None, + target_entity: None, + datapack_manager: Some(&dm), + depth: Cell::new(0), + }; + + let arg_int = ProviderArg::Id(Identifier::parse("my_pack:calc_add").unwrap()); + let (val, name) = evaluate_int(&arg_int, &ctx).unwrap(); + assert_eq!(val, 42); + assert_eq!(name.as_deref(), Some("my_pack:calc_add")); + + let arg_float = ProviderArg::Id(Identifier::parse("my_pack:float_calc").unwrap()); + let (val, name) = evaluate_float(&arg_float, &ctx).unwrap(); + assert!((val - 6.0).abs() < 1e-6); + assert_eq!(name.as_deref(), Some("my_pack:float_calc")); + + // Circular reference detection + dm.insert_context_int_provider( + "my_pack:loop_a".to_string(), + NbtTag::Compound(parse_compound( + "{type:\"add\",left:\"my_pack:loop_b\",right:1}", + )), + ); + dm.insert_context_int_provider( + "my_pack:loop_b".to_string(), + NbtTag::Compound(parse_compound( + "{type:\"add\",left:\"my_pack:loop_a\",right:1}", + )), + ); + let arg_loop = ProviderArg::Id(Identifier::parse("my_pack:loop_a").unwrap()); + assert!(evaluate_int(&arg_loop, &ctx).is_err()); + } +} diff --git a/crates/pumpkin/src/command/commands/loot.rs b/crates/pumpkin/src/command/commands/loot.rs index 07e6b5fa5..01b6d699b 100644 --- a/crates/pumpkin/src/command/commands/loot.rs +++ b/crates/pumpkin/src/command/commands/loot.rs @@ -27,6 +27,8 @@ use crate::command::errors::command_syntax_error::CommandSyntaxError; use crate::command::errors::error_types::CommandErrorType; use crate::command::node::dispatcher::CommandDispatcher; use crate::command::node::{CommandExecutor, CommandExecutorResult}; +use crate::command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; +use crate::command::suggestion::suggestions::SuggestionsBuilder; use crate::entity::EntityBase; use crate::world::loot::LootContextParameters; @@ -39,6 +41,23 @@ static ERROR_INVALID_LOOT_TABLE: CommandErrorType<1> = CommandErrorType::new( "Loot table '%s' not found", ); +struct LootTableSuggestionProvider; + +impl SuggestionProvider for LootTableSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + let server = context.server(); + let custom_names = server.datapack_manager.get_loot_table_names(); + for name in custom_names { + builder = builder.suggest(name); + } + builder.build() + } +} + static ERROR_NO_HELD_ITEMS: CommandErrorType<1> = CommandErrorType::new( translation::java::COMMANDS_DROP_NO_HELD_ITEMS, translation::java::COMMANDS_DROP_NO_HELD_ITEMS, @@ -345,8 +364,11 @@ impl CommandExecutor for LootExecutor { ToolSource::OffHand => get_hand_item(context, false)?, }; - let loot_table = - pumpkin_data::loot_table::get_loot_table(&key).ok_or_else(|| { + let loot_table = context + .server() + .datapack_manager + .get_loot_table(&key) + .ok_or_else(|| { ERROR_INVALID_LOOT_TABLE .create_without_context(TextComponent::text(loot_table_str.to_string())) })?; @@ -361,7 +383,7 @@ impl CommandExecutor for LootExecutor { ..Default::default() }; let seed: i64 = rand::random(); - drops = crate::world::loot::generate_loot_with_context(loot_table, seed, ¶ms); + drops = crate::world::loot::generate_loot_from_handle(&loot_table, seed, ¶ms); } Source::Loot => { let loot_table_str = StringArgumentType::get(context, "loot_table")?; @@ -371,8 +393,11 @@ impl CommandExecutor for LootExecutor { format!("minecraft:{loot_table_str}") }; - let loot_table = - pumpkin_data::loot_table::get_loot_table(&key).ok_or_else(|| { + let loot_table = context + .server() + .datapack_manager + .get_loot_table(&key) + .ok_or_else(|| { ERROR_INVALID_LOOT_TABLE .create_without_context(TextComponent::text(loot_table_str.to_string())) })?; @@ -382,7 +407,7 @@ impl CommandExecutor for LootExecutor { ..Default::default() }; let seed: i64 = rand::random(); - drops = crate::world::loot::generate_loot_with_context(loot_table, seed, ¶ms); + drops = crate::world::loot::generate_loot_from_handle(&loot_table, seed, ¶ms); } Source::Kill => { let target_entities = EntityArgumentType::get_entities(context, "target_entity")?; @@ -402,10 +427,13 @@ impl CommandExecutor for LootExecutor { for entity in &target_entities { let resource_name = entity.get_entity().entity_type.resource_name; let key = format!("minecraft:entities/{resource_name}"); - if let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(&key) { + if let Some(loot_table) = context.server().datapack_manager.get_loot_table(&key) + { let seed: i64 = rand::random(); - drops.extend(crate::world::loot::generate_loot_with_context( - loot_table, seed, ¶ms, + drops.extend(crate::world::loot::generate_loot_from_handle( + &loot_table, + seed, + ¶ms, )); last_key = Some(key); } @@ -427,8 +455,11 @@ impl CommandExecutor for LootExecutor { let block = world.get_block(&pos); let key = format!("minecraft:blocks/{}", block.name); - let loot_table = - pumpkin_data::loot_table::get_loot_table(&key).ok_or_else(|| { + let loot_table = context + .server() + .datapack_manager + .get_loot_table(&key) + .ok_or_else(|| { ERROR_NO_BLOCK_LOOT_TABLE .create_without_context(TextComponent::text(block.name.to_string())) })?; @@ -451,7 +482,7 @@ impl CommandExecutor for LootExecutor { ..Default::default() }; let seed: i64 = rand::random(); - drops = crate::world::loot::generate_loot_with_context(loot_table, seed, ¶ms); + drops = crate::world::loot::generate_loot_from_handle(&loot_table, seed, ¶ms); table_id_for_callback = Some(key); } } @@ -589,41 +620,45 @@ impl CommandExecutor for LootExecutor { fn add_sources(target: Target) -> Vec { vec![ literal("fish").then( - argument("loot_table", StringArgumentType::SingleWord).then( - argument("fish_pos", BlockPosArgumentType) - .executes(LootExecutor { - target, - source: Source::Fish { - tool: ToolSource::None, - }, - }) - .then( - argument("tool", ItemStackArgumentType).executes(LootExecutor { + argument("loot_table", StringArgumentType::SingleWord) + .suggests(LootTableSuggestionProvider) + .then( + argument("fish_pos", BlockPosArgumentType) + .executes(LootExecutor { target, source: Source::Fish { - tool: ToolSource::Item, + tool: ToolSource::None, }, - }), - ) - .then(literal("mainhand").executes(LootExecutor { - target, - source: Source::Fish { - tool: ToolSource::MainHand, - }, - })) - .then(literal("offhand").executes(LootExecutor { - target, - source: Source::Fish { - tool: ToolSource::OffHand, - }, - })), - ), + }) + .then( + argument("tool", ItemStackArgumentType).executes(LootExecutor { + target, + source: Source::Fish { + tool: ToolSource::Item, + }, + }), + ) + .then(literal("mainhand").executes(LootExecutor { + target, + source: Source::Fish { + tool: ToolSource::MainHand, + }, + })) + .then(literal("offhand").executes(LootExecutor { + target, + source: Source::Fish { + tool: ToolSource::OffHand, + }, + })), + ), ), literal("loot").then( - argument("loot_table", StringArgumentType::SingleWord).executes(LootExecutor { - target, - source: Source::Loot, - }), + argument("loot_table", StringArgumentType::SingleWord) + .suggests(LootTableSuggestionProvider) + .executes(LootExecutor { + target, + source: Source::Loot, + }), ), literal("kill").then( argument("target_entity", EntityArgumentType::Entities).executes(LootExecutor { diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index df82b6b04..e90238d85 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -14,6 +14,7 @@ mod banlist; mod bossbar; mod clear; mod clone; +mod compute; mod damage; mod data; mod datapack; @@ -174,6 +175,7 @@ pub fn default_dispatcher( effect::register(&mut dispatcher, registry); summon::register(&mut dispatcher, registry); swing::register(&mut dispatcher, registry); + compute::register(&mut dispatcher, registry); damage::register(&mut dispatcher, registry); rotate::register(&mut dispatcher, registry); tellraw::register(&mut dispatcher, registry); diff --git a/crates/pumpkin/src/data/datapack/context_provider_loader.rs b/crates/pumpkin/src/data/datapack/context_provider_loader.rs new file mode 100644 index 000000000..6fdb45d98 --- /dev/null +++ b/crates/pumpkin/src/data/datapack/context_provider_loader.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use serde_json::Value; +use tracing::warn; + +pub type ContextProviderRegistry = HashMap; + +#[must_use] +pub fn json_value_to_nbt(value: &Value) -> NbtTag { + match value { + Value::Null => NbtTag::String(String::new().into()), + Value::Bool(val) => NbtTag::Byte(i8::from(*val)), + Value::Number(val) => val.as_i64().map_or_else( + || { + val.as_u64().map_or_else( + || NbtTag::Double(val.as_f64().unwrap_or_default()), + |u_val| { + i32::try_from(u_val).map_or_else( + |_| { + #[allow(clippy::cast_precision_loss)] + i64::try_from(u_val) + .map_or_else(|_| NbtTag::Double(u_val as f64), NbtTag::Long) + }, + NbtTag::Int, + ) + }, + ) + }, + |i_val| i32::try_from(i_val).map_or(NbtTag::Long(i_val), NbtTag::Int), + ), + Value::String(val) => NbtTag::String(val.clone().into()), + Value::Array(values) => NbtTag::List(values.iter().map(json_value_to_nbt).collect()), + Value::Object(values) => { + let mut compound = NbtCompound::new(); + for (name, val) in values { + compound.put(name, json_value_to_nbt(val)); + } + NbtTag::Compound(compound) + } + } +} + +pub fn load_context_providers_from_dir( + namespace: &str, + provider_dir: &Path, + registry: &mut ContextProviderRegistry, +) -> usize { + if !provider_dir.is_dir() { + return 0; + } + + let before = registry.len(); + load_context_providers_recursive(namespace, provider_dir, provider_dir, registry); + registry.len() - before +} + +fn load_context_providers_recursive( + namespace: &str, + base_dir: &Path, + current_dir: &Path, + registry: &mut ContextProviderRegistry, +) { + let Ok(entries) = fs::read_dir(current_dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + load_context_providers_recursive(namespace, base_dir, &path, registry); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + && let Ok(rel_path) = path.strip_prefix(base_dir) + { + let mut stem_path = rel_path.to_string_lossy().to_string(); + + if let Some(stem) = stem_path.strip_suffix(".json") { + stem_path = stem.to_string(); + } + + let stem_path = stem_path.replace('\\', "/"); + let provider_id = format!("{namespace}:{stem_path}"); + + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(val) => { + let tag = json_value_to_nbt(&val); + registry.insert(provider_id, tag); + } + Err(e) => { + warn!( + "Failed to parse context provider '{provider_id}' from '{}': {e}", + path.display() + ); + } + }, + Err(e) => { + warn!( + "Failed to read context provider file '{}': {e}", + path.display() + ); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_value_to_nbt_works() { + let json: Value = serde_json::json!({ + "type": "minecraft:add", + "left": 10, + "right": 20.5, + "items": ["a", "b"] + }); + + let tag = json_value_to_nbt(&json); + if let NbtTag::Compound(compound) = tag { + assert_eq!(compound.get_string("type"), Some("minecraft:add")); + assert_eq!(compound.get_int("left"), Some(10)); + assert_eq!(compound.get_double("right"), Some(20.5)); + } else { + panic!("Expected compound"); + } + } +} diff --git a/crates/pumpkin/src/data/datapack/damage_type_loader.rs b/crates/pumpkin/src/data/datapack/damage_type_loader.rs new file mode 100644 index 000000000..251011775 --- /dev/null +++ b/crates/pumpkin/src/data/datapack/damage_type_loader.rs @@ -0,0 +1,417 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use pumpkin_data::damage::{DamageEffects, DamageScaling, DamageType, DeathMessageType}; +use pumpkin_data::registry::RegistryEntryData; +use pumpkin_nbt::{Nbt, NbtCompound}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DamageScalingWrapper { + Never, + WhenCausedByLivingNonPlayer, + Always, +} + +impl From for DamageScaling { + fn from(wrapper: DamageScalingWrapper) -> Self { + match wrapper { + DamageScalingWrapper::Never => Self::Never, + DamageScalingWrapper::WhenCausedByLivingNonPlayer => Self::WhenCausedByLivingNonPlayer, + DamageScalingWrapper::Always => Self::Always, + } + } +} + +impl DamageScalingWrapper { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Never => "never", + Self::WhenCausedByLivingNonPlayer => "when_caused_by_living_non_player", + Self::Always => "always", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DamageEffectsWrapper { + Hurt, + Thorns, + Drowning, + Burning, + Poking, + Freezing, +} + +impl From for DamageEffects { + fn from(wrapper: DamageEffectsWrapper) -> Self { + match wrapper { + DamageEffectsWrapper::Hurt => Self::Hurt, + DamageEffectsWrapper::Thorns => Self::Thorns, + DamageEffectsWrapper::Drowning => Self::Drowning, + DamageEffectsWrapper::Burning => Self::Burning, + DamageEffectsWrapper::Poking => Self::Poking, + DamageEffectsWrapper::Freezing => Self::Freezing, + } + } +} + +impl DamageEffectsWrapper { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Hurt => "hurt", + Self::Thorns => "thorns", + Self::Drowning => "drowning", + Self::Burning => "burning", + Self::Poking => "poking", + Self::Freezing => "freezing", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeathMessageTypeWrapper { + Default, + FallVariants, + IntentionalGameDesign, +} + +impl From for DeathMessageType { + fn from(wrapper: DeathMessageTypeWrapper) -> Self { + match wrapper { + DeathMessageTypeWrapper::Default => Self::Default, + DeathMessageTypeWrapper::FallVariants => Self::FallVariants, + DeathMessageTypeWrapper::IntentionalGameDesign => Self::IntentionalGameDesign, + } + } +} + +impl DeathMessageTypeWrapper { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Default => "default", + Self::FallVariants => "fall_variants", + Self::IntentionalGameDesign => "intentional_game_design", + } + } +} + +/// A parsed damage type definition from a datapack JSON file. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DamageTypeDefinition { + pub message_id: String, + pub scaling: DamageScalingWrapper, + #[serde(default)] + pub exhaustion: f32, + #[serde(default)] + pub effects: Option, + #[serde(default)] + pub death_message_type: Option, +} + +/// A registered damage type in the runtime registry. +#[derive(Clone, Debug)] +pub struct DamageTypeEntry { + pub id: String, + pub numeric_id: u8, + pub definition: DamageTypeDefinition, + pub damage_type: DamageType, + pub nbt_data: Box<[u8]>, +} + +impl DamageTypeEntry { + #[must_use] + pub fn to_registry_entry(&self) -> RegistryEntryData { + RegistryEntryData { + entry_id: self.id.clone(), + data: Some(self.nbt_data.clone()), + } + } +} + +pub type DamageTypeRegistry = HashMap; + +/// Encodes a `DamageTypeDefinition` into a `pumpkin_data::damage::DamageType`. +#[must_use] +pub fn to_damage_type(numeric_id: u8, def: &DamageTypeDefinition) -> DamageType { + DamageType { + death_message_type: def + .death_message_type + .map_or(DeathMessageType::Default, Into::into), + exhaustion: def.exhaustion, + effects: def.effects.map(Into::into), + message_id: Box::leak(def.message_id.clone().into_boxed_str()), + scaling: def.scaling.into(), + id: numeric_id, + } +} + +/// Encodes the damage type definition into binary NBT payload. +#[must_use] +pub fn to_registry_nbt_bytes(def: &DamageTypeDefinition) -> Box<[u8]> { + let mut nbt = NbtCompound::new(); + nbt.put_string("message_id", def.message_id.clone()); + nbt.put_string("scaling", def.scaling.as_str().to_string()); + nbt.put_double("exhaustion", f64::from(def.exhaustion)); + + if let Some(effects) = def.effects { + nbt.put_string("effects", effects.as_str().to_string()); + } + + if let Some(dmt) = def.death_message_type + && dmt != DeathMessageTypeWrapper::Default + { + nbt.put_string("death_message_type", dmt.as_str().to_string()); + } + + Nbt::from(nbt).write_unnamed().to_vec().into_boxed_slice() +} + +/// Encodes the damage type definition into a `RegistryEntryData` with binary NBT payload. +#[must_use] +pub fn to_registry_entry(entry_id: String, def: &DamageTypeDefinition) -> RegistryEntryData { + RegistryEntryData { + entry_id, + data: Some(to_registry_nbt_bytes(def)), + } +} + +/// Loads all damage type JSON definitions from a directory into the definition registry. +pub fn load_damage_types_from_dir( + namespace: &str, + dir: &Path, + registry: &mut HashMap, +) -> usize { + if !dir.is_dir() { + return 0; + } + let before = registry.len(); + load_damage_types_recursive(namespace, dir, dir, registry); + registry.len() - before +} + +fn load_damage_types_recursive( + namespace: &str, + base_dir: &Path, + current_dir: &Path, + registry: &mut HashMap, +) { + let Ok(entries) = fs::read_dir(current_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + load_damage_types_recursive(namespace, base_dir, &path, registry); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + && let Ok(rel_path) = path.strip_prefix(base_dir) + { + let mut stem_path = rel_path.to_string_lossy().to_string(); + if let Some(stem) = stem_path.strip_suffix(".json") { + stem_path = stem.to_string(); + } + let stem_path = stem_path.replace('\\', "/"); + let damage_type_id = format!("{namespace}:{stem_path}"); + + if let Ok(content) = fs::read_to_string(&path) + && let Ok(definition) = serde_json::from_str::(&content) + { + registry.insert(damage_type_id, definition); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_damage_type_json() { + let json = r#"{ + "message_id": "bleed", + "scaling": "when_caused_by_living_non_player", + "exhaustion": 0.25, + "effects": "burning", + "death_message_type": "intentional_game_design" + }"#; + + let def: DamageTypeDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(def.message_id, "bleed"); + assert_eq!( + def.scaling, + DamageScalingWrapper::WhenCausedByLivingNonPlayer + ); + assert!((def.exhaustion - 0.25).abs() < 1e-6); + assert_eq!(def.effects, Some(DamageEffectsWrapper::Burning)); + assert_eq!( + def.death_message_type, + Some(DeathMessageTypeWrapper::IntentionalGameDesign) + ); + + let dt = to_damage_type(52, &def); + assert_eq!(dt.id, 52); + assert_eq!(dt.message_id, "bleed"); + assert_eq!(dt.scaling, DamageScaling::WhenCausedByLivingNonPlayer); + assert_eq!(dt.effects, Some(DamageEffects::Burning)); + assert_eq!( + dt.death_message_type, + DeathMessageType::IntentionalGameDesign + ); + } + + #[test] + fn parse_minimal_damage_type_json() { + let json = r#"{ + "message_id": "simple", + "scaling": "never" + }"#; + + let def: DamageTypeDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(def.message_id, "simple"); + assert_eq!(def.scaling, DamageScalingWrapper::Never); + assert_eq!(def.exhaustion, 0.0); + assert_eq!(def.effects, None); + assert_eq!(def.death_message_type, None); + + let dt = to_damage_type(51, &def); + assert_eq!(dt.id, 51); + assert_eq!(dt.message_id, "simple"); + assert_eq!(dt.scaling, DamageScaling::Never); + assert_eq!(dt.effects, None); + assert_eq!(dt.death_message_type, DeathMessageType::Default); + } + + #[test] + fn to_registry_entry_encodes_valid_nbt() { + let def = DamageTypeDefinition { + message_id: "frost".to_string(), + scaling: DamageScalingWrapper::Always, + exhaustion: 0.1, + effects: Some(DamageEffectsWrapper::Freezing), + death_message_type: Some(DeathMessageTypeWrapper::FallVariants), + }; + + let entry = to_registry_entry("custom:frost".to_string(), &def); + assert_eq!(entry.entry_id, "custom:frost"); + assert!(entry.data.is_some()); + + let data = entry.data.unwrap(); + let mut cursor = std::io::Cursor::new(&data[..]); + let mut reader = pumpkin_nbt::deserializer::NbtReadHelperJava::new( + pumpkin_nbt::deserializer::NbtStreamReader(&mut cursor), + ); + let nbt = Nbt::read_unnamed(&mut reader).unwrap().root_tag; + assert_eq!(nbt.get_string("message_id"), Some("frost")); + assert_eq!(nbt.get_string("scaling"), Some("always")); + assert_eq!(nbt.get_string("effects"), Some("freezing")); + assert_eq!(nbt.get_string("death_message_type"), Some("fall_variants")); + } + + #[test] + fn load_damage_types_from_directory_recursively() { + let temp_dir = + std::env::temp_dir().join(format!("pumpkin_test_dt_{}", uuid::Uuid::new_v4())); + let sub_dir = temp_dir.join("sub"); + fs::create_dir_all(&sub_dir).unwrap(); + + let dt1_json = r#"{ + "message_id": "bleeding", + "scaling": "never", + "exhaustion": 0.5 + }"#; + fs::write(temp_dir.join("bleed.json"), dt1_json).unwrap(); + + let dt2_json = r#"{ + "message_id": "radiation", + "scaling": "always", + "exhaustion": 1.0, + "effects": "poking" + }"#; + fs::write(sub_dir.join("rad.json"), dt2_json).unwrap(); + + let mut registry = HashMap::new(); + let count = load_damage_types_from_dir("custom", &temp_dir, &mut registry); + assert_eq!(count, 2); + assert!(registry.contains_key("custom:bleed")); + assert!(registry.contains_key("custom:sub/rad")); + + let rad_def = ®istry["custom:sub/rad"]; + assert_eq!(rad_def.message_id, "radiation"); + assert_eq!(rad_def.scaling, DamageScalingWrapper::Always); + assert_eq!(rad_def.effects, Some(DamageEffectsWrapper::Poking)); + + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn merge_damage_type_entries_preserves_vanilla_and_appends_custom() { + use crate::data::datapack::merge_damage_type_entries; + + let vanilla = vec![ + RegistryEntryData { + entry_id: "arrow".to_string(), + data: Some(vec![1, 2, 3].into_boxed_slice()), + }, + RegistryEntryData { + entry_id: "cactus".to_string(), + data: Some(vec![4, 5, 6].into_boxed_slice()), + }, + ]; + + let mut custom = HashMap::new(); + let bleed_def = DamageTypeDefinition { + message_id: "bleed".to_string(), + scaling: DamageScalingWrapper::Never, + exhaustion: 0.1, + effects: None, + death_message_type: None, + }; + let bleed_entry = DamageTypeEntry { + id: "custom:bleed".to_string(), + numeric_id: 51, + definition: bleed_def.clone(), + damage_type: to_damage_type(51, &bleed_def), + nbt_data: vec![7, 8, 9].into_boxed_slice(), + }; + custom.insert("custom:bleed".to_string(), bleed_entry); + + // Overriding vanilla "arrow" + let arrow_def = DamageTypeDefinition { + message_id: "custom_arrow".to_string(), + scaling: DamageScalingWrapper::Always, + exhaustion: 0.5, + effects: None, + death_message_type: None, + }; + let arrow_override = DamageTypeEntry { + id: "minecraft:arrow".to_string(), + numeric_id: 0, + definition: arrow_def.clone(), + damage_type: to_damage_type(0, &arrow_def), + nbt_data: vec![99, 99].into_boxed_slice(), + }; + custom.insert("minecraft:arrow".to_string(), arrow_override); + + let merged = merge_damage_type_entries(&vanilla, &custom); + assert_eq!(merged.len(), 3); + // Arrow replaced in place at index 0 + assert_eq!(merged[0].entry_id, "arrow"); + assert_eq!(merged[0].data.as_deref(), Some(&[99, 99][..])); + // Cactus preserved at index 1 + assert_eq!(merged[1].entry_id, "cactus"); + assert_eq!(merged[1].data.as_deref(), Some(&[4, 5, 6][..])); + // Custom bleed appended at index 2 + assert_eq!(merged[2].entry_id, "custom:bleed"); + assert_eq!(merged[2].data.as_deref(), Some(&[7, 8, 9][..])); + } +} diff --git a/crates/pumpkin/src/data/datapack/loot_table_loader.rs b/crates/pumpkin/src/data/datapack/loot_table_loader.rs new file mode 100644 index 000000000..ed440734b --- /dev/null +++ b/crates/pumpkin/src/data/datapack/loot_table_loader.rs @@ -0,0 +1,503 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use pumpkin_util::loot_table::{ + DynamicLootCondition, DynamicLootEntry, DynamicLootPool, DynamicLootTable, LootBonusFormula, +}; +use serde_json::Value; + +/// Parses an entire loot table JSON string into a [`DynamicLootTable`]. +#[must_use] +pub fn parse_loot_table(json_content: &str) -> Option { + let root: Value = serde_json::from_str(json_content).ok()?; + let pools_val = root.get("pools").and_then(Value::as_array)?; + + let pools = pools_val.iter().map(parse_pool).collect(); + + Some(DynamicLootTable { pools }) +} + +fn parse_pool(val: &Value) -> DynamicLootPool { + let (min_rolls, max_rolls) = val.get("rolls").map_or((1, 1), parse_rolls); + + let mut entries = Vec::new(); + let mut empty_weight = 0; + + if let Some(entries_val) = val.get("entries").and_then(Value::as_array) { + for entry_val in entries_val { + parse_entry(entry_val, &mut entries, &mut empty_weight); + } + } + + let pool_conditions = val.get("conditions").map_or(Vec::new(), parse_conditions); + let condition = combine_conditions(pool_conditions); + + DynamicLootPool { + entries, + min_rolls, + max_rolls, + empty_weight, + condition, + } +} + +fn parse_rolls(val: &Value) -> (i32, i32) { + if let Some(n) = val.as_i64() { + let r = n as i32; + return (r, r); + } + if let Some(f) = val.as_f64() { + let r = f as i32; + return (r, r); + } + if let Some(obj) = val.as_object() { + if let (Some(min), Some(max)) = (obj.get("min"), obj.get("max")) { + let min_r = min + .as_i64() + .or_else(|| min.as_f64().map(|f| f as i64)) + .unwrap_or(1) as i32; + let max_r = max + .as_i64() + .or_else(|| max.as_f64().map(|f| f as i64)) + .unwrap_or(min_r as i64) as i32; + return (min_r, max_r); + } + if let Some(value) = obj.get("value") { + let r = value + .as_i64() + .or_else(|| value.as_f64().map(|f| f as i64)) + .unwrap_or(1) as i32; + return (r, r); + } + if let Some(n) = obj.get("n").and_then(Value::as_i64) { + return (0, n as i32); + } + } + (1, 1) +} + +fn parse_entry(val: &Value, pool_entries: &mut Vec, empty_weight: &mut i32) { + let entry_type = val + .get("type") + .and_then(Value::as_str) + .unwrap_or("minecraft:item"); + let entry_type = entry_type.strip_prefix("minecraft:").unwrap_or(entry_type); + let weight = val.get("weight").and_then(Value::as_i64).unwrap_or(1) as i32; + + match entry_type { + "empty" => { + *empty_weight += weight; + } + "item" | "tag" => { + if let Some(name) = val.get("name").and_then(Value::as_str) { + let (min_count, max_count, bonus_formula) = + val.get("functions").map_or((1, 1, None), parse_functions); + let conditions = val.get("conditions").map_or(Vec::new(), parse_conditions); + let condition = combine_conditions(conditions); + + pool_entries.push(DynamicLootEntry { + item: name.to_string(), + weight, + min_count, + max_count, + condition, + bonus_formula, + }); + } + } + "alternatives" | "group" | "sequence" => { + if let Some(children) = val.get("children").and_then(Value::as_array) { + for child in children { + parse_entry(child, pool_entries, empty_weight); + } + } + } + _ => {} + } +} + +fn parse_functions(val: &Value) -> (i32, i32, Option) { + let mut min_count = 1; + let mut max_count = 1; + let mut bonus_formula = None; + + let Some(functions) = val.as_array() else { + return (min_count, max_count, bonus_formula); + }; + + for func in functions { + let func_type = func + .get("function") + .and_then(Value::as_str) + .unwrap_or_default(); + let func_type = func_type.strip_prefix("minecraft:").unwrap_or(func_type); + + match func_type { + "set_count" => { + if let Some(count) = func.get("count") { + let (min_c, max_c) = parse_rolls(count); + min_count = min_c; + max_count = max_c; + } + } + "apply_bonus" => { + if let Some(formula) = func.get("formula").and_then(Value::as_str) { + let formula_clean = formula.strip_prefix("minecraft:").unwrap_or(formula); + match formula_clean { + "ore_drops" => { + bonus_formula = Some(LootBonusFormula::OreDrops); + } + "uniform_bonus_count" => { + let multiplier = func + .get("parameters") + .and_then(|p| p.get("bonusMultiplier")) + .and_then(Value::as_i64) + .unwrap_or(1) as i32; + bonus_formula = Some(LootBonusFormula::UniformBonusCount(multiplier)); + } + "binomial_with_bonus_count" => { + let extra = func + .get("parameters") + .and_then(|p| p.get("extra")) + .and_then(Value::as_i64) + .unwrap_or(0) as i32; + let probability = + func.get("parameters") + .and_then(|p| p.get("probability")) + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + bonus_formula = Some(LootBonusFormula::BinomialWithBonusCount { + extra, + probability, + }); + } + _ => {} + } + } + } + "looting_enchant" => { + let max_bonus = func.get("count").map_or(1, |c| { + c.get("max") + .and_then(Value::as_i64) + .or_else(|| c.as_i64()) + .unwrap_or(1) as i32 + }); + bonus_formula = Some(LootBonusFormula::UniformBonusCount(max_bonus.max(1))); + } + _ => {} + } + } + + (min_count, max_count, bonus_formula) +} + +fn parse_conditions(val: &Value) -> Vec { + let Some(arr) = val.as_array() else { + return Vec::new(); + }; + arr.iter().map(parse_condition).collect() +} + +fn parse_condition(val: &Value) -> DynamicLootCondition { + let cond_type = val + .get("condition") + .and_then(Value::as_str) + .unwrap_or_default(); + let cond_type = cond_type.strip_prefix("minecraft:").unwrap_or(cond_type); + + match cond_type { + "survives_explosion" => DynamicLootCondition::SurvivesExplosion, + "killed_by_player" => DynamicLootCondition::KilledByPlayer, + "random_chance" => { + let chance = val.get("chance").and_then(Value::as_f64).unwrap_or(1.0) as f32; + DynamicLootCondition::RandomChance { chance } + } + "random_chance_with_enchanted_bonus" => { + let unenchanted = val + .get("unenchanted_chance") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + let enchanted_base = val + .get("enchanted_chance") + .or_else(|| val.get("enchanted_chance_base")) + .and_then(Value::as_f64) + .unwrap_or(f64::from(unenchanted)) as f32; + let per_level = val + .get("enchanted_chance_per_level_above_first") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + DynamicLootCondition::RandomChanceWithEnchantedBonus { + unenchanted_chance: unenchanted, + enchanted_chance_base: enchanted_base, + enchanted_chance_per_level_above_first: per_level, + } + } + "table_bonus" => { + let chances: Vec = val + .get("chances") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_f64().map(|f| f as f32)) + .collect() + }) + .unwrap_or_default(); + DynamicLootCondition::TableBonus { + chances: chances.into_boxed_slice(), + } + } + "inverted" => val.get("term").map_or(DynamicLootCondition::None, |term| { + DynamicLootCondition::Inverted(Box::new(parse_condition(term))) + }), + "any_of" => val + .get("terms") + .and_then(Value::as_array) + .map_or(DynamicLootCondition::None, |terms| { + DynamicLootCondition::AnyOf(terms.iter().map(parse_condition).collect()) + }), + "all_of" => val + .get("terms") + .and_then(Value::as_array) + .map_or(DynamicLootCondition::None, |terms| { + DynamicLootCondition::AllOf(terms.iter().map(parse_condition).collect()) + }), + "match_tool" => { + let pred_str = val + .get("predicate") + .map_or_else(String::new, Value::to_string); + let has_silk = pred_str.contains("silk_touch"); + let has_shears = pred_str.contains("shears"); + if has_silk && has_shears { + DynamicLootCondition::SilkTouchOrShears + } else if has_silk { + DynamicLootCondition::SilkTouch + } else if has_shears { + DynamicLootCondition::Shears + } else { + DynamicLootCondition::None + } + } + "entity_properties" => { + let on_fire = val + .get("predicate") + .and_then(|p| p.get("flags")) + .and_then(|f| f.get("is_on_fire")) + .and_then(Value::as_bool) + .unwrap_or(false); + if on_fire { + DynamicLootCondition::EntityOnFire + } else { + DynamicLootCondition::None + } + } + "weather_check" => { + let raining = val.get("raining").and_then(Value::as_bool); + let thundering = val.get("thundering").and_then(Value::as_bool); + DynamicLootCondition::WeatherCheck { + raining, + thundering, + } + } + _ => DynamicLootCondition::None, + } +} + +fn combine_conditions(conditions: Vec) -> DynamicLootCondition { + if conditions.is_empty() { + DynamicLootCondition::None + } else if conditions.len() == 1 { + conditions.into_iter().next().unwrap_or_default() + } else { + DynamicLootCondition::AllOf(conditions) + } +} + +/// Loads all loot table JSON files from a directory recursively into `registry`. +pub fn load_loot_tables_from_dir( + namespace: &str, + dir: &Path, + registry: &mut HashMap, S>, +) -> usize { + if !dir.is_dir() { + return 0; + } + let before = registry.len(); + load_loot_tables_recursive(namespace, dir, dir, registry); + registry.len() - before +} + +fn load_loot_tables_recursive( + namespace: &str, + base_dir: &Path, + current_dir: &Path, + registry: &mut HashMap, S>, +) { + let Ok(entries) = fs::read_dir(current_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + load_loot_tables_recursive(namespace, base_dir, &path, registry); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + && let Ok(rel_path) = path.strip_prefix(base_dir) + { + let mut stem_path = rel_path.to_string_lossy().to_string(); + if let Some(stem) = stem_path.strip_suffix(".json") { + stem_path = stem.to_string(); + } + let stem_path = stem_path.replace('\\', "/"); + let loot_table_id = format!("{namespace}:{stem_path}"); + + if let Ok(content) = fs::read_to_string(&path) + && let Some(table) = parse_loot_table(&content) + { + registry.insert(loot_table_id, Arc::new(table)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_simple_loot_table() { + let json = r#"{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 2, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond", + "weight": 5, + "functions": [ + { + "function": "minecraft:set_count", + "count": {"min": 1, "max": 3} + } + ] + }, + { + "type": "minecraft:empty", + "weight": 10 + } + ] + } + ] + }"#; + + let table = parse_loot_table(json).expect("valid loot table"); + assert_eq!(table.pools.len(), 1); + let pool = &table.pools[0]; + assert_eq!(pool.min_rolls, 2); + assert_eq!(pool.max_rolls, 2); + assert_eq!(pool.empty_weight, 10); + assert_eq!(pool.entries.len(), 1); + assert_eq!(pool.entries[0].item, "minecraft:diamond"); + assert_eq!(pool.entries[0].weight, 5); + assert_eq!(pool.entries[0].min_count, 1); + assert_eq!(pool.entries[0].max_count, 3); + } + + #[test] + fn parse_block_loot_with_silk_touch_and_fortune() { + let json = r#"{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:alternatives", + "children": [ + { + "type": "minecraft:item", + "name": "minecraft:coal_ore", + "conditions": [ + { + "condition": "minecraft:match_tool", + "predicate": { + "predicates": { + "minecraft:enchantments": [ + {"enchantments": "minecraft:silk_touch"} + ] + } + } + } + ] + }, + { + "type": "minecraft:item", + "name": "minecraft:coal", + "functions": [ + { + "function": "minecraft:apply_bonus", + "formula": "minecraft:ore_drops" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] + } + ] + } + ] + }"#; + + let table = parse_loot_table(json).expect("valid block loot table"); + assert_eq!(table.pools.len(), 1); + let pool = &table.pools[0]; + assert_eq!(pool.entries.len(), 2); + assert_eq!(pool.entries[0].item, "minecraft:coal_ore"); + assert_eq!(pool.entries[0].condition, DynamicLootCondition::SilkTouch); + assert_eq!(pool.entries[1].item, "minecraft:coal"); + assert_eq!( + pool.entries[1].bonus_formula, + Some(LootBonusFormula::OreDrops) + ); + assert_eq!( + pool.entries[1].condition, + DynamicLootCondition::SurvivesExplosion + ); + } + + #[test] + fn directory_loading() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + let sub = root.join("blocks"); + fs::create_dir_all(&sub).expect("create dir"); + + let table_json = r#"{ + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:dirt" + } + ] + } + ] + }"#; + fs::write(sub.join("dirt.json"), table_json).expect("write json"); + + let mut registry = HashMap::new(); + let count = load_loot_tables_from_dir("custom", root, &mut registry); + assert_eq!(count, 1); + assert!(registry.contains_key("custom:blocks/dirt")); + } +} diff --git a/crates/pumpkin/src/data/datapack/mod.rs b/crates/pumpkin/src/data/datapack/mod.rs index 9c995f329..bb0f17de6 100644 --- a/crates/pumpkin/src/data/datapack/mod.rs +++ b/crates/pumpkin/src/data/datapack/mod.rs @@ -1,4 +1,7 @@ +pub mod context_provider_loader; +pub mod damage_type_loader; pub mod function_loader; +pub mod loot_table_loader; pub mod recipe_loader; pub mod test_loader; @@ -8,14 +11,20 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use tracing::{info, warn}; +use pumpkin_data::damage::DamageType; use pumpkin_data::registry::RegistryEntryData; +use pumpkin_nbt::tag::NbtTag; use pumpkin_nbt::{NbtCompound, nbt_compress::read_gzip_compound_tag}; use pumpkin_protocol::codec::recipe::DynamicRecipe; +use pumpkin_util::loot_table::DynamicLootTable; use crate::command::context::command_source::CommandSource; use crate::server::Server; use crate::server::recipe::RecipeManager; +use crate::world::loot::LootTableHandle; +use self::context_provider_loader::ContextProviderRegistry; +use self::damage_type_loader::{DamageTypeDefinition, DamageTypeEntry, DamageTypeRegistry}; use self::test_loader::{ TestInstance, TestInstanceRegistry, load_test_instances_from_dir, to_registry_entry, }; @@ -63,6 +72,10 @@ pub struct DatapackManager { functions: RwLock>>, function_tags: RwLock>>, test_instances: RwLock, + context_int_providers: RwLock, + context_float_providers: RwLock, + damage_types: RwLock, + loot_tables: RwLock>>, } fn share_function_bodies( @@ -88,6 +101,10 @@ impl DatapackManager { functions: RwLock::new(HashMap::new()), function_tags: RwLock::new(HashMap::new()), test_instances: RwLock::new(HashMap::new()), + context_int_providers: RwLock::new(HashMap::new()), + context_float_providers: RwLock::new(HashMap::new()), + damage_types: RwLock::new(HashMap::new()), + loot_tables: RwLock::new(HashMap::new()), } } @@ -103,6 +120,10 @@ impl DatapackManager { let mut all_functions: HashMap> = HashMap::new(); let mut all_function_tags: HashMap> = HashMap::new(); let mut all_test_instances: TestInstanceRegistry = HashMap::new(); + let mut all_context_int_providers: ContextProviderRegistry = HashMap::new(); + let mut all_context_float_providers: ContextProviderRegistry = HashMap::new(); + let mut all_damage_type_defs: HashMap = HashMap::new(); + let mut all_loot_tables: HashMap> = HashMap::new(); // Embedded test instances are compile-time constants and always load, // so on-disk packs can override them by id. @@ -110,60 +131,25 @@ impl DatapackManager { if embedded_count > 0 { info!("Loaded {embedded_count} embedded test instance(s)"); } - if datapacks_dir.is_dir() { - match fs::read_dir(&datapacks_dir) { - Ok(entries) => { - for entry in entries.flatten() { - let pack_path = entry.path(); - let file_name = entry.file_name().to_string_lossy().to_string(); + let mut acc = PackContentAccumulators { + recipes: &mut all_recipes, + functions: &mut all_functions, + function_tags: &mut all_function_tags, + test_instances: &mut all_test_instances, + damage_types: &mut all_damage_type_defs, + context_int_providers: &mut all_context_int_providers, + context_float_providers: &mut all_context_float_providers, + loot_tables: &mut all_loot_tables, + }; - if file_name.starts_with('.') || !pack_path.is_dir() { - continue; - } + scan_datapacks_dir( + &datapacks_dir, + enabled_packs, + &mut acc, + &mut loaded_packs_vec, + ); - let pack_id = format!("file/{file_name}"); - let is_enabled = enabled_packs - .iter() - .any(|p| p == &pack_id || p == &file_name); - if !is_enabled { - continue; - } - - let (description, pack_format, known_packs) = read_pack_mcmeta(&pack_path); - - let (pack_recipe_count, pack_function_count, pack_test_instance_count) = - load_pack_contents( - &pack_path, - &mut all_recipes, - &mut all_functions, - &mut all_function_tags, - &mut all_test_instances, - ); - - info!( - "Loaded datapack '{file_name}': {pack_recipe_count} recipe(s), {pack_function_count} function(s), {pack_test_instance_count} test instance(s)" - ); - - loaded_packs_vec.push(LoadedDatapack { - id: pack_id, - name: file_name, - description, - pack_format, - root_path: pack_path, - recipe_count: pack_recipe_count, - function_count: pack_function_count, - known_packs, - }); - } - } - Err(error) => { - warn!( - "Failed to read datapacks directory '{}': {error}", - datapacks_dir.display() - ); - } - } - } + let damage_type_registry = build_damage_type_registry(all_damage_type_defs); recipe_manager.set_recipes(all_recipes); *self @@ -183,6 +169,22 @@ impl DatapackManager { .test_instances .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = all_test_instances; + *self + .context_int_providers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = all_context_int_providers; + *self + .context_float_providers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = all_context_float_providers; + *self + .damage_types + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = damage_type_registry; + *self + .loot_tables + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = all_loot_tables; } pub fn get_loaded_packs(&self) -> Vec { @@ -219,6 +221,162 @@ impl DatapackManager { names } + #[must_use] + pub fn get_context_int_provider(&self, id: &str) -> Option { + self.context_int_providers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .cloned() + } + + #[must_use] + pub fn get_context_float_provider(&self, id: &str) -> Option { + self.context_float_providers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .cloned() + } + + #[must_use] + pub fn get_context_int_provider_names(&self) -> Vec { + let providers = self + .context_int_providers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut names: Vec<_> = providers.keys().cloned().collect(); + names.sort_unstable(); + names + } + + #[must_use] + pub fn get_context_float_provider_names(&self) -> Vec { + let providers = self + .context_float_providers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut names: Vec<_> = providers.keys().cloned().collect(); + names.sort_unstable(); + names + } + + pub fn insert_context_int_provider(&self, id: String, tag: NbtTag) { + self.context_int_providers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, tag); + } + + pub fn insert_context_float_provider(&self, id: String, tag: NbtTag) { + self.context_float_providers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, tag); + } + + #[must_use] + pub fn get_loot_table(&self, key: &str) -> Option { + let full_key = if key.contains(':') { + key.to_string() + } else { + format!("minecraft:{key}") + }; + + let guard = self + .loot_tables + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Some(table) = guard.get(&full_key).or_else(|| guard.get(key)) { + return Some(LootTableHandle::Dynamic(table.clone())); + } + + pumpkin_data::loot_table::get_loot_table(key) + .or_else(|| pumpkin_data::loot_table::get_loot_table(&full_key)) + .map(LootTableHandle::Static) + } + + #[must_use] + pub fn get_loot_table_names(&self) -> Vec { + let guard = self + .loot_tables + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut names: Vec<_> = guard.keys().cloned().collect(); + names.sort_unstable(); + names + } + + pub fn insert_loot_table(&self, key: String, table: Arc) { + self.loot_tables + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(key, table); + } + + #[must_use] + pub fn get_damage_type(&self, id: &str) -> Option { + let guard = self + .damage_types + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let full_id = if id.contains(':') { + id.to_string() + } else { + format!("minecraft:{id}") + }; + guard + .get(&full_id) + .or_else(|| guard.get(id)) + .map(|e| e.damage_type) + } + + #[must_use] + pub fn get_damage_type_names(&self) -> Vec { + let guard = self + .damage_types + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut names: Vec<_> = guard.keys().cloned().collect(); + names.sort_unstable(); + names + } + + #[must_use] + pub fn get_damage_type_registry_map(&self) -> HashMap { + self.damage_types + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + #[must_use] + pub fn get_all_damage_type_registry_entries(&self) -> Vec { + let guard = self + .damage_types + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut entries: Vec = guard + .values() + .map(DamageTypeEntry::to_registry_entry) + .collect(); + entries.sort_by(|a, b| a.entry_id.cmp(&b.entry_id)); + entries + } + + #[must_use] + pub fn merge_damage_type_entries( + &self, + vanilla_entries: &[RegistryEntryData], + ) -> Vec { + let guard = self + .damage_types + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + merge_damage_type_entries(vanilla_entries, &guard) + } + /// Returns datapack test instances in the protocol's synced-registry entry format. /// The vanilla Test Instance Block renderer resolves required/padding/base rotation /// through this registry using the controller's `data.test` resource key. @@ -868,19 +1026,153 @@ fn parse_structure_resource_location(resource_location: &str) -> Result<(&str, & Ok((namespace, path)) } -/// Loads recipes, functions, function tags, and test instances from a single -/// datapack directory, returning per-pack counts as `(recipes, functions, test_instances)`. +fn build_damage_type_registry( + mut all_damage_type_defs: HashMap, +) -> DamageTypeRegistry { + let mut damage_type_registry: DamageTypeRegistry = HashMap::new(); + let mut next_custom_id = 51u8; + let mut static_map: HashMap = HashMap::new(); + let mut names = Vec::new(); + + let mut sorted_ids: Vec = all_damage_type_defs.keys().cloned().collect(); + sorted_ids.sort(); + + for id in sorted_ids { + let Some(def) = all_damage_type_defs.remove(&id) else { + continue; + }; + let path = id.split_once(':').map_or(id.as_str(), |(_, p)| p); + let numeric_id = pumpkin_data::damage::DamageType::from_name(path) + .or_else(|| pumpkin_data::damage::DamageType::from_name(&id)) + .map_or_else( + || { + let nid = next_custom_id; + next_custom_id = next_custom_id.saturating_add(1); + nid + }, + |vanilla_dt| vanilla_dt.id, + ); + + let dt = damage_type_loader::to_damage_type(numeric_id, &def); + let nbt_data = damage_type_loader::to_registry_nbt_bytes(&def); + let leaked_dt = Box::leak(Box::new(dt)); + static_map.insert(id.clone(), leaked_dt); + static_map.insert(path.to_string(), leaked_dt); + names.push(id.clone()); + + damage_type_registry.insert( + id.clone(), + DamageTypeEntry { + id, + numeric_id, + definition: def, + damage_type: dt, + nbt_data, + }, + ); + } + + pumpkin_command::argument_types::resource::register_dynamic_damage_types( + Box::new(move |req_id: &str| static_map.get(req_id).copied()), + names, + ); + + damage_type_registry +} + +struct PackContentAccumulators<'a> { + recipes: &'a mut Vec, + functions: &'a mut HashMap>, + function_tags: &'a mut HashMap>, + test_instances: &'a mut TestInstanceRegistry, + damage_types: &'a mut HashMap, + context_int_providers: &'a mut ContextProviderRegistry, + context_float_providers: &'a mut ContextProviderRegistry, + loot_tables: &'a mut HashMap>, +} + +#[derive(Default, Clone, Copy)] +struct PackContentCounts { + recipes: usize, + functions: usize, + test_instances: usize, + damage_types: usize, + int_providers: usize, + float_providers: usize, + loot_tables: usize, +} + +fn scan_datapacks_dir( + datapacks_dir: &Path, + enabled_packs: &[String], + acc: &mut PackContentAccumulators<'_>, + loaded_packs_vec: &mut Vec, +) { + if !datapacks_dir.is_dir() { + return; + } + let entries = match fs::read_dir(datapacks_dir) { + Ok(entries) => entries, + Err(error) => { + warn!( + "Failed to read datapacks directory '{}': {error}", + datapacks_dir.display() + ); + return; + } + }; + + for entry in entries.flatten() { + let pack_path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name.starts_with('.') || !pack_path.is_dir() { + continue; + } + + let pack_id = format!("file/{file_name}"); + let is_enabled = enabled_packs + .iter() + .any(|p| p == &pack_id || p == &file_name); + if !is_enabled { + continue; + } + + let (description, pack_format, known_packs) = read_pack_mcmeta(&pack_path); + let counts = load_pack_contents(&pack_path, acc); + + info!( + "Loaded datapack '{file_name}': {} recipe(s), {} function(s), {} test instance(s), {} damage type(s), {} int provider(s), {} float provider(s), {} loot table(s)", + counts.recipes, + counts.functions, + counts.test_instances, + counts.damage_types, + counts.int_providers, + counts.float_providers, + counts.loot_tables, + ); + + loaded_packs_vec.push(LoadedDatapack { + id: pack_id, + name: file_name, + description, + pack_format, + root_path: pack_path, + recipe_count: counts.recipes, + function_count: counts.functions, + known_packs, + }); + } +} + +/// Loads recipes, functions, function tags, test instances, damage types, context number providers, +/// and loot tables from a single datapack directory, returning per-pack counts as [`PackContentCounts`]. fn load_pack_contents( pack_path: &Path, - all_recipes: &mut Vec, - all_functions: &mut HashMap>, - all_function_tags: &mut HashMap>, - all_test_instances: &mut TestInstanceRegistry, -) -> (usize, usize, usize) { + acc: &mut PackContentAccumulators<'_>, +) -> PackContentCounts { let data_dir = pack_path.join("data"); - let mut pack_recipe_count = 0; - let mut pack_function_count = 0; - let mut pack_test_instance_count = 0; + let mut counts = PackContentCounts::default(); if data_dir.is_dir() && let Ok(ns_entries) = fs::read_dir(&data_dir) @@ -899,8 +1191,8 @@ fn load_pack_contents( load_recipes_from_dir( &namespace, &recipe_dir, - all_recipes, - &mut pack_recipe_count, + acc.recipes, + &mut counts.recipes, ); } } @@ -909,9 +1201,9 @@ fn load_pack_contents( for fn_sub in ["function", "functions"] { let fn_dir = ns_path.join(fn_sub); if fn_dir.is_dir() { - let before = all_functions.len(); - function_loader::load_functions_from_dir(&namespace, &fn_dir, all_functions); - pack_function_count += all_functions.len() - before; + let before = acc.functions.len(); + function_loader::load_functions_from_dir(&namespace, &fn_dir, acc.functions); + counts.functions += acc.functions.len() - before; } } @@ -921,26 +1213,126 @@ fn load_pack_contents( function_loader::load_function_tags_from_dir( &namespace, &tags_dir, - all_function_tags, + acc.function_tags, ); } // Load game test instances let test_instance_dir = ns_path.join("test_instance"); if test_instance_dir.is_dir() { - pack_test_instance_count += load_test_instances_from_dir( + counts.test_instances += load_test_instances_from_dir( &namespace, &test_instance_dir, - all_test_instances, + acc.test_instances, ); } + + // Load damage types + for dt_sub in ["damage_type", "damage_types"] { + let dt_dir = ns_path.join(dt_sub); + if dt_dir.is_dir() { + counts.damage_types += damage_type_loader::load_damage_types_from_dir( + &namespace, + &dt_dir, + acc.damage_types, + ); + } + } + + // Load context int providers + for int_sub in ["context_int_provider", "context_int_providers"] { + let provider_dir = ns_path.join(int_sub); + if provider_dir.is_dir() { + counts.int_providers += + context_provider_loader::load_context_providers_from_dir( + &namespace, + &provider_dir, + acc.context_int_providers, + ); + } + } + + // Load context float providers + for float_sub in ["context_float_provider", "context_float_providers"] { + let provider_dir = ns_path.join(float_sub); + if provider_dir.is_dir() { + counts.float_providers += + context_provider_loader::load_context_providers_from_dir( + &namespace, + &provider_dir, + acc.context_float_providers, + ); + } + } + + // Load loot tables + for lt_sub in ["loot_table", "loot_tables"] { + let lt_dir = ns_path.join(lt_sub); + if lt_dir.is_dir() { + counts.loot_tables += loot_table_loader::load_loot_tables_from_dir( + &namespace, + <_dir, + acc.loot_tables, + ); + } + } } } - ( - pack_recipe_count, - pack_function_count, - pack_test_instance_count, - ) + counts +} + +#[must_use] +pub fn clone_registry_entry(entry: &RegistryEntryData) -> RegistryEntryData { + RegistryEntryData { + entry_id: entry.entry_id.clone(), + data: entry.data.clone(), + } +} + +#[must_use] +pub fn merge_damage_type_entries( + vanilla_entries: &[RegistryEntryData], + custom_entries: &HashMap, +) -> Vec { + if custom_entries.is_empty() { + return vanilla_entries.iter().map(clone_registry_entry).collect(); + } + + let mut merged = Vec::with_capacity(vanilla_entries.len() + custom_entries.len()); + let mut overridden = std::collections::HashSet::new(); + + for entry in vanilla_entries { + let full_id = if entry.entry_id.contains(':') { + entry.entry_id.clone() + } else { + format!("minecraft:{}", entry.entry_id) + }; + + if let Some(custom) = custom_entries + .get(&full_id) + .or_else(|| custom_entries.get(&entry.entry_id)) + { + let mut reg_entry = custom.to_registry_entry(); + reg_entry.entry_id.clone_from(&entry.entry_id); + merged.push(reg_entry); + overridden.insert(full_id); + overridden.insert(entry.entry_id.clone()); + } else { + merged.push(clone_registry_entry(entry)); + } + } + + let mut sorted_custom: Vec<&DamageTypeEntry> = custom_entries + .values() + .filter(|e| !overridden.contains(&e.id)) + .collect(); + sorted_custom.sort_by_key(|e| e.numeric_id); + + for custom in sorted_custom { + merged.push(custom.to_registry_entry()); + } + + merged } fn read_pack_mcmeta(pack_path: &Path) -> (String, u32, Vec) { diff --git a/crates/pumpkin/src/entity/living.rs b/crates/pumpkin/src/entity/living.rs index 2bac0c2ec..c6eb5b924 100644 --- a/crates/pumpkin/src/entity/living.rs +++ b/crates/pumpkin/src/entity/living.rs @@ -2273,11 +2273,12 @@ impl LivingEntity { fn drop_loot(&self, params: &LootContextParameters) { let resource_name = self.get_entity().entity_type.resource_name; let key = format!("minecraft:entities/{resource_name}"); - if let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(&key) { + let world = self.entity.world.load(); + if let Some(loot_table) = world.get_loot_table(&key) { let seed: i64 = rand::random(); let pos = self.entity.block_pos.load(); - for stack in crate::world::loot::generate_loot_with_context(loot_table, seed, params) { - self.entity.world.load().drop_stack(&pos, stack); + for stack in crate::world::loot::generate_loot_from_handle(&loot_table, seed, params) { + world.drop_stack(&pos, stack); } } } diff --git a/crates/pumpkin/src/entity/vehicle/minecart/container.rs b/crates/pumpkin/src/entity/vehicle/minecart/container.rs index 5419459f0..44c543804 100644 --- a/crates/pumpkin/src/entity/vehicle/minecart/container.rs +++ b/crates/pumpkin/src/entity/vehicle/minecart/container.rs @@ -15,8 +15,6 @@ use pumpkin_util::math::vector3::Vector3; use pumpkin_util::text::TextComponent; use crate::entity::{Entity, player::Player}; -use crate::world::loot::fill_chest_inventory; -use pumpkin_data::loot_table::get_loot_table; pub(super) struct MinecartInventory { items: RwLock>, @@ -98,7 +96,7 @@ impl MinecartInventory { let Some((loot_table, seed)) = loot_table else { return; }; - let Some(table) = get_loot_table(&loot_table) else { + let Some(table) = crate::world::loot::get_loot_table(&loot_table) else { *self .loot_table .lock() @@ -107,7 +105,7 @@ impl MinecartInventory { }; let inventory: Arc = self.clone(); - fill_chest_inventory(&inventory, table, seed); + crate::world::loot::fill_chest_inventory_handle(&inventory, &table, seed); } } diff --git a/crates/pumpkin/src/item/items/brush.rs b/crates/pumpkin/src/item/items/brush.rs index 2a2bde5d8..b56ea08dd 100644 --- a/crates/pumpkin/src/item/items/brush.rs +++ b/crates/pumpkin/src/item/items/brush.rs @@ -43,8 +43,8 @@ fn get_archaeology_loot(is_sand: bool, location: BlockPos, world: &World) -> Ite "minecraft:archaeology/trail_ruins_common" }; - if let Some(table) = pumpkin_data::loot_table::get_loot_table(loot_key) { - let items = crate::world::loot::generate_loot(table, rand::random()); + if let Some(table) = world.get_loot_table(loot_key) { + let items = table.generate_loot(rand::random()); if let Some(first) = items.into_iter().next() { return first; } diff --git a/crates/pumpkin/src/item/items/hoe.rs b/crates/pumpkin/src/item/items/hoe.rs index cc9cdecdc..73a02ed56 100644 --- a/crates/pumpkin/src/item/items/hoe.rs +++ b/crates/pumpkin/src/item/items/hoe.rs @@ -50,10 +50,10 @@ impl ItemBehaviour for HoeItem { world.set_block_state(&location, result.new_state_id, BlockFlags::NOTIFY_ALL); if let Some(loot_key) = result.entry.loot - && let Some(loot_table) = pumpkin_data::loot_table::get_loot_table(loot_key) + && let Some(loot_table) = world.get_loot_table(loot_key) { let seed = rand::random::(); - let drops = crate::world::loot::generate_loot(loot_table, seed); + let drops = loot_table.generate_loot(seed); for drop_stack in drops { if result.entry.drop_strategy == Some(DropStrategy::ClickedFace) { world.drop_stack_from_face(&location, face, drop_stack); diff --git a/crates/pumpkin/src/net/java/config/known_packs.rs b/crates/pumpkin/src/net/java/config/known_packs.rs index 06ca6f636..9f8480111 100644 --- a/crates/pumpkin/src/net/java/config/known_packs.rs +++ b/crates/pumpkin/src/net/java/config/known_packs.rs @@ -1,5 +1,7 @@ #[allow(clippy::wildcard_imports)] use super::*; +use bytes::Bytes; +use std::collections::HashMap; impl JavaClient { pub async fn handle_known_packs(&self, server: &Server) -> Option { @@ -15,90 +17,14 @@ impl JavaClient { let test_instance_entries = server.datapack_manager.get_test_instance_registry_entries(); + let custom_damage_type_map = server.datapack_manager.get_damage_type_registry_map(); let packets = tokio::task::spawn_blocking(move || { - let registry = Registry::get_synced(version); - let mut packets = Vec::new(); - let mut sent_dimension_type = false; - let mut sent_test_instance = false; - - for reg in ®istry { - if reg.registry_id == "minecraft:dimension_type" { - sent_dimension_type = true; - } - - if reg.registry_id == "minecraft:test_instance" { - sent_test_instance = true; - - let packet = CRegistryData::new(®.registry_id, &test_instance_entries); - - if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { - packets.push(data); - } - - continue; - } - - let packet = CRegistryData::new(®.registry_id, ®.registry_entries); - - if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { - packets.push(data); - } - } - - // The generated synced-registry table can lag behind the current protocol. - // ResourceSelectorArgument validates /test names against this registry on the - // client, so always provide it even when the static table omitted it. - if !sent_test_instance { - let test_instance = "minecraft:test_instance".to_string(); - let packet = CRegistryData::new(&test_instance, &test_instance_entries); - - if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { - packets.push(data); - } - } - - if !sent_dimension_type { - let dims = [ - &pumpkin_data::dimension::Dimension::OVERWORLD, - &pumpkin_data::dimension::Dimension::OVERWORLD_CAVES, - &pumpkin_data::dimension::Dimension::THE_END, - &pumpkin_data::dimension::Dimension::THE_NETHER, - ]; - - let dim_entries: Vec = dims - .iter() - .map(|dim| pumpkin_data::registry::RegistryEntryData { - entry_id: dim.minecraft_name.to_string(), - data: Some(build_dimension_nbt(dim).into_boxed_slice()), - }) - .collect(); - - let dim_type = "minecraft:dimension_type".to_string(); - let packet = CRegistryData::new(&dim_type, &dim_entries); - - if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { - packets.push(data); - } - } - - let mut tags = Vec::new(); - - for &key in pumpkin_data::tag::RegistryKey::NETWORK_KEYS { - if pumpkin_data::tag::get_registry_key_tags(version, key) - .is_some_and(|map| !map.is_empty()) - { - tags.push(key); - } - } - - let packet = CUpdateTags::new(&tags); - - if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { - packets.push(data); - } - - packets + Self::build_synced_registry_packets( + version, + &test_instance_entries, + &custom_damage_type_map, + ) }) .await .unwrap_or_default(); @@ -118,4 +44,132 @@ impl JavaClient { debug!("Finished config"); None } + + fn build_synced_registry_packets( + version: JavaMinecraftVersion, + test_instance_entries: &[pumpkin_data::registry::RegistryEntryData], + custom_damage_type_map: &HashMap< + String, + crate::data::datapack::damage_type_loader::DamageTypeEntry, + S, + >, + ) -> Vec { + let registry = Registry::get_synced(version); + let mut packets = Vec::new(); + let mut sent_dimension_type = false; + let mut sent_test_instance = false; + let mut sent_damage_type = false; + + for reg in ®istry { + if reg.registry_id == "minecraft:dimension_type" { + sent_dimension_type = true; + } + + if reg.registry_id == "minecraft:test_instance" { + sent_test_instance = true; + + let packet = CRegistryData::new(®.registry_id, test_instance_entries); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + + continue; + } + + if reg.registry_id == "minecraft:damage_type" { + sent_damage_type = true; + + let merged_damage_types = crate::data::datapack::merge_damage_type_entries( + ®.registry_entries, + custom_damage_type_map, + ); + + let packet = CRegistryData::new(®.registry_id, &merged_damage_types); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + + continue; + } + + let packet = CRegistryData::new(®.registry_id, ®.registry_entries); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + } + + // The generated synced-registry table can lag behind the current protocol. + // ResourceSelectorArgument validates /test names against this registry on the + // client, so always provide it even when the static table omitted it. + if !sent_test_instance { + let test_instance = "minecraft:test_instance".to_string(); + let packet = CRegistryData::new(&test_instance, test_instance_entries); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + } + + if !sent_damage_type && !custom_damage_type_map.is_empty() { + let damage_type = "minecraft:damage_type".to_string(); + let merged_damage_types = + crate::data::datapack::merge_damage_type_entries(&[], custom_damage_type_map); + let packet = CRegistryData::new(&damage_type, &merged_damage_types); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + } + + if !sent_dimension_type { + let dim_entries = get_fallback_dimension_type_entries(); + let dim_type = "minecraft:dimension_type".to_string(); + let packet = CRegistryData::new(&dim_type, &dim_entries); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + } + + let tags = get_network_tag_keys(version); + let packet = CUpdateTags::new(&tags); + + if let Ok(data) = Self::serialize_packet_for_version(&packet, version) { + packets.push(data); + } + + packets + } +} + +fn get_fallback_dimension_type_entries() -> Vec { + let dims = [ + &pumpkin_data::dimension::Dimension::OVERWORLD, + &pumpkin_data::dimension::Dimension::OVERWORLD_CAVES, + &pumpkin_data::dimension::Dimension::THE_END, + &pumpkin_data::dimension::Dimension::THE_NETHER, + ]; + + dims.iter() + .map(|dim| pumpkin_data::registry::RegistryEntryData { + entry_id: dim.minecraft_name.to_string(), + data: Some(build_dimension_nbt(dim).into_boxed_slice()), + }) + .collect() +} + +fn get_network_tag_keys(version: JavaMinecraftVersion) -> Vec { + let mut tags = Vec::new(); + + for &key in pumpkin_data::tag::RegistryKey::NETWORK_KEYS { + if pumpkin_data::tag::get_registry_key_tags(version, key).is_some_and(|map| !map.is_empty()) + { + tags.push(key); + } + } + + tags } diff --git a/crates/pumpkin/src/net/java/login/known_packs.rs b/crates/pumpkin/src/net/java/login/known_packs.rs index 2102fe4d9..d7242ec49 100644 --- a/crates/pumpkin/src/net/java/login/known_packs.rs +++ b/crates/pumpkin/src/net/java/login/known_packs.rs @@ -11,6 +11,14 @@ impl PendingConnection { } let registry = pumpkin_data::registry::Registry::get_synced(version); for reg in ®istry { + if reg.registry_id == "minecraft:damage_type" { + let merged = server + .datapack_manager + .merge_damage_type_entries(®.registry_entries); + self.send_packet_now(&CRegistryData::new(®.registry_id, &merged)) + .await; + continue; + } self.send_packet_now(&CRegistryData::new(®.registry_id, ®.registry_entries)) .await; } diff --git a/crates/pumpkin/src/world/loot.rs b/crates/pumpkin/src/world/loot.rs index fce690f46..e40d43e04 100644 --- a/crates/pumpkin/src/world/loot.rs +++ b/crates/pumpkin/src/world/loot.rs @@ -3,7 +3,10 @@ use pumpkin_data::damage::DamageType; use pumpkin_data::entity::EntityType; use pumpkin_data::item::Item; use pumpkin_data::item_stack::ItemStack; -use pumpkin_util::loot_table::{LootBonusFormula, LootCondition, LootEntry, LootTable}; +pub use pumpkin_util::loot_table::{ + DynamicLootCondition, DynamicLootEntry, DynamicLootPool, DynamicLootTable, LootBonusFormula, + LootCondition, LootEntry, LootPool, LootTable, +}; use pumpkin_util::random::{RandomImpl, xoroshiro128::Xoroshiro}; #[derive(Default, Clone)] @@ -26,6 +29,74 @@ pub struct LootContextParameters { pub is_on_fire: Option, } +fn check_dynamic_condition( + cond: &DynamicLootCondition, + has_silk_touch: bool, + has_shears: bool, + fortune_level: i32, + params: &LootContextParameters, + rng: &mut Xoroshiro, +) -> bool { + match cond { + DynamicLootCondition::None => true, + DynamicLootCondition::SilkTouch => has_silk_touch, + DynamicLootCondition::NoSilkTouch => !has_silk_touch, + DynamicLootCondition::Shears => has_shears, + DynamicLootCondition::SilkTouchOrShears => has_silk_touch || has_shears, + DynamicLootCondition::NoSilkTouchOrShears => !has_silk_touch && !has_shears, + DynamicLootCondition::KilledByPlayer => params.killed_by_player.unwrap_or(false), + DynamicLootCondition::SurvivesExplosion => params + .explosion_radius + .is_none_or(|radius| rng.next_f32() <= 1.0 / radius), + DynamicLootCondition::RandomChance { chance } => rng.next_f32() < *chance, + DynamicLootCondition::RandomChanceWithEnchantedBonus { + unenchanted_chance, + enchanted_chance_base, + enchanted_chance_per_level_above_first, + } => { + let chance = if fortune_level > 0 { + enchanted_chance_base + + enchanted_chance_per_level_above_first * (fortune_level - 1) as f32 + } else { + *unenchanted_chance + }; + rng.next_f32() < chance + } + DynamicLootCondition::TableBonus { chances } => { + let index = (fortune_level.max(0) as usize).min(chances.len().saturating_sub(1)); + chances + .get(index) + .is_some_and(|chance| rng.next_f32() < *chance) + } + DynamicLootCondition::AllOf(conditions) => conditions.iter().all(|c| { + check_dynamic_condition(c, has_silk_touch, has_shears, fortune_level, params, rng) + }), + DynamicLootCondition::AnyOf(conditions) => conditions.iter().any(|c| { + check_dynamic_condition(c, has_silk_touch, has_shears, fortune_level, params, rng) + }), + DynamicLootCondition::Inverted(cond) => { + !check_dynamic_condition(cond, has_silk_touch, has_shears, fortune_level, params, rng) + } + DynamicLootCondition::EntityOnFire => params.is_on_fire.unwrap_or(false), + DynamicLootCondition::WeatherCheck { + raining, + thundering, + } => { + if let Some(r) = raining + && params.is_raining != Some(*r) + { + return false; + } + if let Some(t) = thundering + && params.is_thundering != Some(*t) + { + return false; + } + true + } + } +} + fn check_condition( cond: LootCondition, has_silk_touch: bool, @@ -232,13 +303,207 @@ pub fn generate_loot_with_context( pub use generate_loot as generate_chest_loot; -pub fn fill_chest_inventory( +#[must_use] +pub fn generate_dynamic_loot(table: &DynamicLootTable, seed: i64) -> Vec { + generate_dynamic_loot_with_context(table, seed, &LootContextParameters::default()) +} + +#[must_use] +pub fn generate_dynamic_loot_with_context( + table: &DynamicLootTable, + seed: i64, + params: &LootContextParameters, +) -> Vec { + let mut rng = Xoroshiro::from_seed(seed as u64); + let mut items_to_place: Vec = Vec::new(); + + let has_silk_touch = params.tool.as_ref().is_some_and(|tool| { + pumpkin_data::Enchantment::from_name("silk_touch") + .is_some_and(|e| tool.get_enchantment_level(e) > 0) + }); + + let has_shears = params.tool.as_ref().is_some_and(|tool| { + let name = tool + .item + .registry_key + .strip_prefix("minecraft:") + .unwrap_or(tool.item.registry_key); + name == "shears" + }); + + let fortune_level = params.tool.as_ref().map_or(0, |tool| { + let fortune = pumpkin_data::Enchantment::from_name("fortune") + .map_or(0, |e| tool.get_enchantment_level(e)); + let looting = pumpkin_data::Enchantment::from_name("looting") + .map_or(0, |e| tool.get_enchantment_level(e)); + fortune.max(looting) + }); + + for pool in &table.pools { + if !check_dynamic_condition( + &pool.condition, + has_silk_touch, + has_shears, + fortune_level, + params, + &mut rng, + ) { + continue; + } + + let eligible_entries: Vec<&DynamicLootEntry> = pool + .entries + .iter() + .filter(|e| { + check_dynamic_condition( + &e.condition, + has_silk_touch, + has_shears, + fortune_level, + params, + &mut rng, + ) + }) + .collect(); + + if eligible_entries.is_empty() && pool.empty_weight == 0 { + continue; + } + + let range = pool.max_rolls - pool.min_rolls; + let rolls = pool.min_rolls + + if range > 0 { + rng.next_bounded_i32(range + 1) + } else { + 0 + }; + + roll_dynamic_entries( + rolls, + pool.empty_weight, + &eligible_entries, + fortune_level, + &mut rng, + &mut items_to_place, + ); + } + + items_to_place +} + +fn roll_dynamic_entries( + rolls: i32, + empty_weight: i32, + eligible_entries: &[&DynamicLootEntry], + fortune_level: i32, + rng: &mut Xoroshiro, + items_to_place: &mut Vec, +) { + for _ in 0..rolls { + let entry_weight: i32 = eligible_entries.iter().map(|e| e.weight).sum(); + let total_weight = entry_weight + empty_weight; + if total_weight == 0 { + continue; + } + + let mut pick = rng.next_bounded_i32(total_weight); + pick -= empty_weight; + if pick < 0 { + continue; + } + + for entry in eligible_entries { + pick -= entry.weight; + if pick < 0 { + let count_range = entry.max_count - entry.min_count; + let base_count = entry.min_count + + if count_range > 0 { + rng.next_bounded_i32(count_range + 1) + } else { + 0 + }; + + let mut final_count = base_count; + if let Some(bonus) = entry.bonus_formula { + final_count = apply_bonus_formula(final_count, bonus, fortune_level, rng); + } + + if final_count > 0 { + let item_key = entry.item.strip_prefix("minecraft:").unwrap_or(&entry.item); + if let Some(item) = Item::from_registry_key(item_key) { + items_to_place.push(ItemStack::new(final_count as u8, item)); + } + } + break; + } + } + } +} + +/// A handle to either a compile-time static loot table or a datapack dynamic loot table. +#[derive(Clone, Debug)] +pub enum LootTableHandle { + Static(&'static LootTable), + Dynamic(std::sync::Arc), +} + +impl From<&'static LootTable> for LootTableHandle { + fn from(t: &'static LootTable) -> Self { + Self::Static(t) + } +} + +impl From> for LootTableHandle { + fn from(t: std::sync::Arc) -> Self { + Self::Dynamic(t) + } +} + +impl LootTableHandle { + #[must_use] + pub fn generate_loot(&self, seed: i64) -> Vec { + self.generate_loot_with_context(seed, &LootContextParameters::default()) + } + + #[must_use] + pub fn generate_loot_with_context( + &self, + seed: i64, + params: &LootContextParameters, + ) -> Vec { + generate_loot_from_handle(self, seed, params) + } +} + +#[must_use] +pub fn get_loot_table(key: &str) -> Option { + let full_key = if key.contains(':') { + key.to_string() + } else { + format!("minecraft:{key}") + }; + pumpkin_data::loot_table::get_loot_table(key) + .or_else(|| pumpkin_data::loot_table::get_loot_table(&full_key)) + .map(LootTableHandle::Static) +} + +#[must_use] +pub fn generate_loot_from_handle( + handle: &LootTableHandle, + seed: i64, + params: &LootContextParameters, +) -> Vec { + match handle { + LootTableHandle::Static(table) => generate_loot_with_context(table, seed, params), + LootTableHandle::Dynamic(table) => generate_dynamic_loot_with_context(table, seed, params), + } +} + +fn place_items_in_chest( inventory: &std::sync::Arc, - table: &LootTable, + mut items_to_place: Vec, seed: i64, ) { - let mut items_to_place = generate_loot(table, seed); - if items_to_place.is_empty() { return; } @@ -266,6 +531,24 @@ pub fn fill_chest_inventory( } } +pub fn fill_chest_inventory_handle( + inventory: &std::sync::Arc, + handle: &LootTableHandle, + seed: i64, +) { + let items_to_place = generate_loot_from_handle(handle, seed, &LootContextParameters::default()); + place_items_in_chest(inventory, items_to_place, seed); +} + +pub fn fill_chest_inventory( + inventory: &std::sync::Arc, + table: &LootTable, + seed: i64, +) { + let items_to_place = generate_loot(table, seed); + place_items_in_chest(inventory, items_to_place, seed); +} + fn shuffle_and_split_items( result: &mut Vec, available_slots: usize, diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 73f349488..99db19b4f 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -7279,6 +7279,23 @@ impl World { } } + #[must_use] + pub fn get_loot_table(&self, key: &str) -> Option { + self.server.upgrade().map_or_else( + || { + let full_key = if key.contains(':') { + key.to_string() + } else { + format!("minecraft:{key}") + }; + pumpkin_data::loot_table::get_loot_table(key) + .or_else(|| pumpkin_data::loot_table::get_loot_table(&full_key)) + .map(crate::world::loot::LootTableHandle::Static) + }, + |server| server.datapack_manager.get_loot_table(key), + ) + } + pub fn generate_loot(&self, loot_table: String) { let mut loot_event = crate::plugin::api::events::world::loot_generate::LootGenerateEvent::new(loot_table);