From df28c321ac0931581a41688fbb03fefe4ddb6f7e Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sat, 22 Aug 2026 18:56:48 +0200 Subject: [PATCH] feat: basic initial datapack support --- crates/pumpkin-data/src/generated/biome.rs | 4 +- .../pumpkin-data/src/generated/structures.rs | 672 +++++++- crates/pumpkin-util/src/y_offset.rs | 8 +- .../src/generation/structure/mod.rs | 15 +- .../generation/structure/structures/jigsaw.rs | 49 +- .../structure/structures/jigsaw_placement.rs | 127 +- .../structure/structures/nether_fossil.rs | 12 +- .../structures/stronghold/chest_corridor.rs | 24 +- .../structures/stronghold/library.rs | 27 +- .../structures/stronghold/square_room.rs | 4 +- .../src/generation/structure/template/mod.rs | 133 +- .../structure/template/processor.rs | 1129 ++++++++++++-- .../structure/template/structure_template.rs | 1350 ++++++++++++++--- .../pumpkin/src/command/commands/datapack.rs | 681 +++++++++ .../pumpkin/src/command/commands/function.rs | 110 ++ crates/pumpkin/src/command/commands/mod.rs | 4 + crates/pumpkin/src/command/commands/place.rs | 6 +- .../src/data/datapack/function_loader.rs | 127 ++ crates/pumpkin/src/data/datapack/mod.rs | 275 ++++ .../src/data/datapack/recipe_loader.rs | 294 ++++ crates/pumpkin/src/data/mod.rs | 1 + crates/pumpkin/src/server/mod.rs | 60 + crates/pumpkin/src/server/recipe.rs | 15 + tools/pumpkin-codegen/src/structures.rs | 442 +++++- 24 files changed, 5090 insertions(+), 479 deletions(-) create mode 100644 crates/pumpkin/src/command/commands/datapack.rs create mode 100644 crates/pumpkin/src/command/commands/function.rs create mode 100644 crates/pumpkin/src/data/datapack/function_loader.rs create mode 100644 crates/pumpkin/src/data/datapack/mod.rs create mode 100644 crates/pumpkin/src/data/datapack/recipe_loader.rs diff --git a/crates/pumpkin-data/src/generated/biome.rs b/crates/pumpkin-data/src/generated/biome.rs index 30f83168b..601a7a70c 100644 --- a/crates/pumpkin-data/src/generated/biome.rs +++ b/crates/pumpkin-data/src/generated/biome.rs @@ -9300,7 +9300,7 @@ impl ParameterPoint { } } #[must_use] -pub fn target( +pub const fn target( temperature: f32, humidity: f32, continentalness: f32, @@ -9318,7 +9318,7 @@ pub fn target( ) } #[must_use] -pub fn parameters( +pub const fn parameters( temperature: f32, humidity: f32, continentalness: f32, diff --git a/crates/pumpkin-data/src/generated/structures.rs b/crates/pumpkin-data/src/generated/structures.rs index 2f6c337fd..4ae63fb8b 100644 --- a/crates/pumpkin-data/src/generated/structures.rs +++ b/crates/pumpkin-data/src/generated/structures.rs @@ -4,6 +4,7 @@ use pumpkin_util::random::{ RandomGenerator, RandomImpl, get_carver_seed, get_region_seed, legacy_rand::LegacyRand, xoroshiro128::Xoroshiro, }; +use pumpkin_util::y_offset::{AboveBottom, Absolute, BelowTop, YOffset}; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum StructureKeys { PillagerOutpost, @@ -247,6 +248,125 @@ pub enum StructureType { SwampHut, Unknown, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoundingBoxType { + Piece, + Full, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpawnEntry { + pub entity_type: &'static str, + pub min_count: u32, + pub max_count: u32, + pub weight: u32, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpawnOverride { + pub category: &'static str, + pub bounding_box: BoundingBoxType, + pub spawns: &'static [SpawnEntry], +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HeightProvider { + Uniform(UniformHeightProvider), + Trapezoid(TrapezoidHeightProvider), + VeryBiasedToBottom(VeryBiasedToBottomHeightProvider), +} +impl HeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + match self { + Self::Uniform(provider) => provider.get(random, min_y, height), + Self::Trapezoid(provider) => provider.get(random, min_y, height), + Self::VeryBiasedToBottom(provider) => provider.get(random, min_y, height), + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UniformHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, +} +impl UniformHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let min = self.min_inclusive.get_y(min_y as i16, height); + let max = self.max_inclusive.get_y(min_y as i16, height); + if min >= max { + min + } else { + random.next_inbetween_i32(min, max) + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TrapezoidHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, + pub plateau: Option, +} +impl TrapezoidHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let plateau = self.plateau.unwrap_or(0); + let i = self.min_inclusive.get_y(min_y as i16, height); + let j = self.max_inclusive.get_y(min_y as i16, height); + if i >= j { + return i; + } + let k = j - i; + if plateau >= k { + return random.next_inbetween_i32(i, j); + } + let l = (k - plateau) / 2; + let m = k - l; + i + random.next_inbetween_i32(0, m) + random.next_inbetween_i32(0, l) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VeryBiasedToBottomHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, + pub inner: Option>, +} +impl VeryBiasedToBottomHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let min = self.min_inclusive.get_y(min_y as i16, height); + let max = self.max_inclusive.get_y(min_y as i16, height); + let inner = self.inner.map_or(1, std::num::NonZero::get) as i32; + if min >= max { + return min; + } + let min_rnd = random.next_inbetween_i32(min + inner, max); + let max_rnd = random.next_inbetween_i32(min, min_rnd - 1); + random.next_inbetween_i32(min, max_rnd - 1 + inner) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PoolAliasBinding { + Direct { + alias: &'static str, + target: &'static str, + }, + Random { + alias: &'static str, + targets: &'static [WeightedPoolAliasTarget], + }, + RandomGroup { + groups: &'static [WeightedPoolAliasGroup], + }, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WeightedPoolAliasTarget { + pub target: &'static str, + pub weight: u32, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WeightedPoolAliasGroup { + pub bindings: &'static [PoolAliasBinding], + pub weight: u32, +} pub struct Structure { pub biomes: &'static str, pub step: GenerationStep, @@ -254,12 +374,19 @@ pub struct Structure { pub start_jigsaw_name: Option<&'static str>, pub size: Option, pub terrain_adaptation: TerrainAdaptation, - pub start_height: Option, + pub start_height: Option, pub project_start_to_heightmap: Option<&'static str>, pub max_distance_from_center: Option, pub liquid_settings: Option<&'static str>, pub dimension_padding: Option, pub use_expansion_hack: Option, + pub pool_aliases: &'static [PoolAliasBinding], + pub spawn_overrides: &'static [SpawnOverride], + pub is_beached: Option, + pub mineshaft_type: Option<&'static str>, + pub biome_temp: Option<&'static str>, + pub cluster_probability: Option, + pub large_probability: Option, pub structure_type: StructureType, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -299,12 +426,63 @@ impl Structure { start_jigsaw_name: Some("minecraft:city_anchor"), size: Some(7i32), terrain_adaptation: TerrainAdaptation::BeardBox, - start_height: Some(-27i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: -27i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: -27i16 }), + })), project_start_to_heightmap: None, max_distance_from_center: Some(116i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(false), + pool_aliases: &[], + spawn_overrides: &[ + SpawnOverride { + category: "ambient", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "axolotls", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "creature", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "misc", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "underground_water_creature", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "water_ambient", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "water_creature", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + ], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const BASTION_REMNANT: Self = Structure { @@ -314,12 +492,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::None, - start_height: Some(33i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 33i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 33i16 }), + })), project_start_to_heightmap: None, max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(false), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const BURIED_TREASURE: Self = Structure { @@ -335,6 +523,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::BuriedTreasure, }; pub const DESERT_PYRAMID: Self = Structure { @@ -350,6 +545,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::DesertPyramid, }; pub const END_CITY: Self = Structure { @@ -365,6 +567,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::EndCity, }; pub const FORTRESS: Self = Structure { @@ -380,6 +589,48 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Piece, + spawns: &[ + SpawnEntry { + entity_type: "minecraft:blaze", + min_count: 2u32, + max_count: 3u32, + weight: 10u32, + }, + SpawnEntry { + entity_type: "minecraft:zombified_piglin", + min_count: 4u32, + max_count: 4u32, + weight: 5u32, + }, + SpawnEntry { + entity_type: "minecraft:wither_skeleton", + min_count: 5u32, + max_count: 5u32, + weight: 8u32, + }, + SpawnEntry { + entity_type: "minecraft:skeleton", + min_count: 5u32, + max_count: 5u32, + weight: 2u32, + }, + SpawnEntry { + entity_type: "minecraft:magma_cube", + min_count: 4u32, + max_count: 4u32, + weight: 3u32, + }, + ], + }], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Fortress, }; pub const IGLOO: Self = Structure { @@ -395,6 +646,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Igloo, }; pub const JUNGLE_PYRAMID: Self = Structure { @@ -410,6 +668,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::JungleTemple, }; pub const MANSION: Self = Structure { @@ -425,6 +690,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::WoodlandMansion, }; pub const MINESHAFT: Self = Structure { @@ -440,6 +712,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: Some("normal"), + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Mineshaft, }; pub const MINESHAFT_MESA: Self = Structure { @@ -455,6 +734,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: Some("mesa"), + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Mineshaft, }; pub const MONUMENT: Self = Structure { @@ -470,6 +756,34 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[ + SpawnOverride { + category: "axolotls", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Full, + spawns: &[SpawnEntry { + entity_type: "minecraft:guardian", + min_count: 2u32, + max_count: 4u32, + weight: 1u32, + }], + }, + SpawnOverride { + category: "underground_water_creature", + bounding_box: BoundingBoxType::Full, + spawns: &[], + }, + ], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::OceanMonument, }; pub const NETHER_FOSSIL: Self = Structure { @@ -479,12 +793,22 @@ impl Structure { start_jigsaw_name: None, size: None, terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: None, + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 32i16 }), + max_inclusive: YOffset::BelowTop(BelowTop { below_top: 2i8 }), + })), project_start_to_heightmap: None, max_distance_from_center: None, liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::NetherFossil, }; pub const OCEAN_RUIN_COLD: Self = Structure { @@ -500,6 +824,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: Some("cold"), + cluster_probability: Some(0.9f32), + large_probability: Some(0.3f32), structure_type: StructureType::OceanRuin, }; pub const OCEAN_RUIN_WARM: Self = Structure { @@ -515,6 +846,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: Some("warm"), + cluster_probability: Some(0.9f32), + large_probability: Some(0.3f32), structure_type: StructureType::OceanRuin, }; pub const PILLAGER_OUTPOST: Self = Structure { @@ -524,12 +862,31 @@ impl Structure { start_jigsaw_name: None, size: Some(7i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Full, + spawns: &[SpawnEntry { + entity_type: "minecraft:pillager", + min_count: 1u32, + max_count: 1u32, + weight: 1u32, + }], + }], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const RUINED_PORTAL: Self = Structure { @@ -545,6 +902,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_DESERT: Self = Structure { @@ -560,6 +924,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_JUNGLE: Self = Structure { @@ -575,6 +946,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_MOUNTAIN: Self = Structure { @@ -590,6 +968,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_NETHER: Self = Structure { @@ -605,6 +990,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_OCEAN: Self = Structure { @@ -620,6 +1012,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const RUINED_PORTAL_SWAMP: Self = Structure { @@ -635,6 +1034,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::RuinedPortal, }; pub const SHIPWRECK: Self = Structure { @@ -650,6 +1056,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: Some(false), + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Shipwreck, }; pub const SHIPWRECK_BEACHED: Self = Structure { @@ -665,6 +1078,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: Some(true), + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Shipwreck, }; pub const STRONGHOLD: Self = Structure { @@ -680,6 +1100,13 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Stronghold, }; pub const SWAMP_HUT: Self = Structure { @@ -695,6 +1122,34 @@ impl Structure { liquid_settings: None, dimension_padding: None, use_expansion_hack: None, + pool_aliases: &[], + spawn_overrides: &[ + SpawnOverride { + category: "creature", + bounding_box: BoundingBoxType::Piece, + spawns: &[SpawnEntry { + entity_type: "minecraft:cat", + min_count: 1u32, + max_count: 1u32, + weight: 1u32, + }], + }, + SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Piece, + spawns: &[SpawnEntry { + entity_type: "minecraft:witch", + min_count: 1u32, + max_count: 1u32, + weight: 1u32, + }], + }, + ], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::SwampHut, }; pub const TRAIL_RUINS: Self = Structure { @@ -704,12 +1159,22 @@ impl Structure { start_jigsaw_name: None, size: Some(7i32), terrain_adaptation: TerrainAdaptation::Bury, - start_height: Some(-15i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: -15i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: -15i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(false), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const TRIAL_CHAMBERS: Self = Structure { @@ -719,12 +1184,145 @@ impl Structure { start_jigsaw_name: None, size: Some(20i32), terrain_adaptation: TerrainAdaptation::Encapsulate, - start_height: Some(-40i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: -40i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: -20i16 }), + })), project_start_to_heightmap: None, max_distance_from_center: Some(116i32), liquid_settings: Some("ignore_waterlogging"), dimension_padding: Some(10i32), use_expansion_hack: Some(false), + pool_aliases: &[ + PoolAliasBinding::RandomGroup { + groups: &[ + WeightedPoolAliasGroup { + bindings: &[ + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/ranged", + target: "minecraft:trial_chambers/spawner/ranged/skeleton", + }, + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/slow_ranged", + target: "minecraft:trial_chambers/spawner/slow_ranged/skeleton", + }, + ], + weight: 1u32, + }, + WeightedPoolAliasGroup { + bindings: &[ + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/ranged", + target: "minecraft:trial_chambers/spawner/ranged/stray", + }, + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/slow_ranged", + target: "minecraft:trial_chambers/spawner/slow_ranged/stray", + }, + ], + weight: 1u32, + }, + WeightedPoolAliasGroup { + bindings: &[ + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/ranged", + target: "minecraft:trial_chambers/spawner/ranged/poison_skeleton", + }, + PoolAliasBinding::Direct { + alias: "minecraft:trial_chambers/spawner/contents/slow_ranged", + target: "minecraft:trial_chambers/spawner/slow_ranged/poison_skeleton", + }, + ], + weight: 1u32, + }, + ], + }, + PoolAliasBinding::Random { + alias: "minecraft:trial_chambers/spawner/contents/melee", + targets: &[ + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/melee/zombie", + weight: 1u32, + }, + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/melee/husk", + weight: 1u32, + }, + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/melee/spider", + weight: 1u32, + }, + ], + }, + PoolAliasBinding::Random { + alias: "minecraft:trial_chambers/spawner/contents/small_melee", + targets: &[ + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/small_melee/slime", + weight: 1u32, + }, + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/small_melee/cave_spider", + weight: 1u32, + }, + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/small_melee/silverfish", + weight: 1u32, + }, + WeightedPoolAliasTarget { + target: "minecraft:trial_chambers/spawner/small_melee/baby_zombie", + weight: 1u32, + }, + ], + }, + ], + spawn_overrides: &[ + SpawnOverride { + category: "ambient", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "axolotls", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "creature", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "misc", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "monster", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "underground_water_creature", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "water_ambient", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + SpawnOverride { + category: "water_creature", + bounding_box: BoundingBoxType::Piece, + spawns: &[], + }, + ], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const VILLAGE_DESERT: Self = Structure { @@ -734,12 +1332,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const VILLAGE_PLAINS: Self = Structure { @@ -749,12 +1357,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const VILLAGE_SAVANNA: Self = Structure { @@ -764,12 +1382,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const VILLAGE_SNOWY: Self = Structure { @@ -779,12 +1407,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; pub const VILLAGE_TAIGA: Self = Structure { @@ -794,12 +1432,22 @@ impl Structure { start_jigsaw_name: None, size: Some(6i32), terrain_adaptation: TerrainAdaptation::BeardThin, - start_height: Some(0i16), + start_height: Some(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + max_inclusive: YOffset::Absolute(Absolute { absolute: 0i16 }), + })), project_start_to_heightmap: Some("WORLD_SURFACE_WG"), max_distance_from_center: Some(80i32), liquid_settings: None, dimension_padding: None, use_expansion_hack: Some(true), + pool_aliases: &[], + spawn_overrides: &[], + is_beached: None, + mineshaft_type: None, + biome_temp: None, + cluster_probability: None, + large_probability: None, structure_type: StructureType::Jigsaw, }; #[must_use] diff --git a/crates/pumpkin-util/src/y_offset.rs b/crates/pumpkin-util/src/y_offset.rs index cf21f4961..8f3d23e78 100644 --- a/crates/pumpkin-util/src/y_offset.rs +++ b/crates/pumpkin-util/src/y_offset.rs @@ -9,7 +9,7 @@ use serde::Deserialize; /// /// The enum is deserialized in an untagged form, meaning the variant /// is selected based on the provided field. -#[derive(Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(untagged)] pub enum YOffset { /// Absolute world Y coordinate. @@ -47,21 +47,21 @@ impl YOffset { } /// Absolute vertical position in world coordinates. -#[derive(Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] pub struct Absolute { /// The fixed world Y level. pub absolute: i16, } /// Offset measured upward from the world's minimum Y level. -#[derive(Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] pub struct AboveBottom { /// Number of blocks above the minimum Y. pub above_bottom: i8, } /// Offset measured downward from the world's top boundary. -#[derive(Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] pub struct BelowTop { /// Number of blocks below the maximum Y. pub below_top: i8, diff --git a/crates/pumpkin-world/src/generation/structure/mod.rs b/crates/pumpkin-world/src/generation/structure/mod.rs index f8909a6c8..bfaf90be3 100644 --- a/crates/pumpkin-world/src/generation/structure/mod.rs +++ b/crates/pumpkin-world/src/generation/structure/mod.rs @@ -81,7 +81,8 @@ pub fn generate_structure_position( let (Some(start_pool), Some(size)) = (structure.start_pool, structure.size) else { return None; }; - let mut generator = JigsawGenerator::new(start_pool, size); + let mut generator = + JigsawGenerator::new(start_pool, size).with_pool_aliases(structure.pool_aliases); if structure.use_expansion_hack.unwrap_or(false) { generator = generator.with_expansion_hack(true); } @@ -92,7 +93,9 @@ pub fn generate_structure_position( } StructureKeys::Shipwreck | StructureKeys::ShipwreckBeached => { let generator = ShipwreckGenerator { - is_beached: *key == StructureKeys::ShipwreckBeached, + is_beached: structure + .is_beached + .unwrap_or(*key == StructureKeys::ShipwreckBeached), }; generator.get_structure_position(context) } @@ -108,7 +111,9 @@ pub fn generate_structure_position( } StructureKeys::OceanRuinCold | StructureKeys::OceanRuinWarm => { let generator = OceanRuinGenerator { - is_warm: *key == StructureKeys::OceanRuinWarm, + is_warm: structure + .biome_temp + .map_or(*key == StructureKeys::OceanRuinWarm, |t| t == "warm"), }; generator.get_structure_position(context) } @@ -117,7 +122,9 @@ pub fn generate_structure_position( StructureKeys::Monument => OceanMonumentGenerator.get_structure_position(context), StructureKeys::Mineshaft | StructureKeys::MineshaftMesa => { let generator = MineshaftGenerator { - is_mesa: *key == StructureKeys::MineshaftMesa, + is_mesa: structure + .mineshaft_type + .map_or(*key == StructureKeys::MineshaftMesa, |t| t == "mesa"), }; generator.get_structure_position(context) } diff --git a/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs b/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs index 8c85d4a09..4fc2e7f09 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs @@ -765,6 +765,7 @@ pub struct JigsawGenerator { pub size: i32, pub start_jigsaw_name: Option, pub use_expansion_hack: bool, + pub pool_aliases: &'static [pumpkin_data::structures::PoolAliasBinding], } impl JigsawGenerator { @@ -775,6 +776,18 @@ impl JigsawGenerator { size, start_jigsaw_name: None, use_expansion_hack: false, + pool_aliases: &[], + } + } + + #[must_use] + pub fn new_with_pool(start_pool: &str, size: i32) -> Self { + Self { + start_pool: start_pool.to_string(), + size, + start_jigsaw_name: None, + use_expansion_hack: false, + pool_aliases: &[], } } @@ -789,6 +802,15 @@ impl JigsawGenerator { self.use_expansion_hack = use_hack; self } + + #[must_use] + pub const fn with_pool_aliases( + mut self, + aliases: &'static [pumpkin_data::structures::PoolAliasBinding], + ) -> Self { + self.pool_aliases = aliases; + self + } } impl StructureGenerator for JigsawGenerator { @@ -801,8 +823,11 @@ impl StructureGenerator for JigsawGenerator { .structure_key .map(|key| pumpkin_data::structures::Structure::get(&key)); + let height = if context.min_y < 0 { 384 } else { 256 }; let start_y = if let Some(s) = structure { - s.start_height.unwrap_or(context.sea_level as i16) as i32 + s.start_height.map_or(context.sea_level, |hp| { + hp.get(&mut context.random, context.min_y as i8, height) + }) } else { context.sea_level }; @@ -847,9 +872,27 @@ impl StructureGenerator for JigsawGenerator { .as_deref() .or_else(|| structure.and_then(|s| s.start_jigsaw_name)); + let pool_aliases = if !self.pool_aliases.is_empty() { + self.pool_aliases + } else if let Some(s) = structure { + s.pool_aliases + } else { + &[] + }; + + let pool_alias_lookup = PoolAliasLookup::from_bindings(pool_aliases, &mut context.random); + + let start_pool = if self.start_pool.is_empty() { + structure + .and_then(|s| s.start_pool) + .unwrap_or(&self.start_pool) + } else { + &self.start_pool + }; + JigsawPlacement::add_pieces( &mut context, - &self.start_pool, + start_pool, start_jigsaw, self.size, start_pos, @@ -858,7 +901,7 @@ impl StructureGenerator for JigsawGenerator { &MaxDistance::new(max_distance), dimension_padding, liquid_settings, - &PoolAliasLookup::default(), + &pool_alias_lookup, ) } } diff --git a/crates/pumpkin-world/src/generation/structure/structures/jigsaw_placement.rs b/crates/pumpkin-world/src/generation/structure/structures/jigsaw_placement.rs index 554455e12..2cc8af4f5 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/jigsaw_placement.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/jigsaw_placement.rs @@ -58,7 +58,7 @@ pub const MAX_DEPTH: i32 = 20; /// Dynamic lookup for Pool Aliases introduced in 1.20+ (e.g. Trial Chambers spawner/contents aliases). #[derive(Debug, Clone, Default)] pub struct PoolAliasLookup { - aliases: HashMap>, + aliases: HashMap, } impl PoolAliasLookup { @@ -69,38 +69,111 @@ impl PoolAliasLookup { } } - pub fn add_direct(&mut self, alias: String, target: String) { - self.aliases.insert(alias, vec![(target, 1)]); + #[must_use] + pub fn from_bindings( + bindings: &[pumpkin_data::structures::PoolAliasBinding], + random: &mut RandomGenerator, + ) -> Self { + let mut lookup = Self::new(); + for binding in bindings { + lookup.add_binding(binding, random); + } + lookup } - pub fn add_random(&mut self, alias: String, targets: Vec<(String, u32)>) { - self.aliases.insert(alias, targets); + fn add_binding( + &mut self, + binding: &pumpkin_data::structures::PoolAliasBinding, + random: &mut RandomGenerator, + ) { + use pumpkin_data::structures::PoolAliasBinding; + match binding { + PoolAliasBinding::Direct { alias, target } => { + self.add_direct((*alias).to_string(), (*target).to_string()); + } + PoolAliasBinding::Random { alias, targets } => { + if let Some(target) = Self::pick_weighted(targets, random, |t| t.weight) { + self.add_direct((*alias).to_string(), target.target.to_string()); + } + } + PoolAliasBinding::RandomGroup { groups } => { + if let Some(group) = Self::pick_weighted(groups, random, |g| g.weight) { + for b in group.bindings { + self.add_binding(b, random); + } + } + } + } + } + + fn pick_weighted<'a, T>( + items: &'a [T], + random: &mut RandomGenerator, + get_weight: impl Fn(&T) -> u32, + ) -> Option<&'a T> { + if items.is_empty() { + return None; + } + let total_weight: u32 = items.iter().map(&get_weight).sum(); + if total_weight == 0 { + return items.first(); + } + let mut r = random.next_bounded_i32(total_weight as i32) as u32; + for item in items { + let w = get_weight(item); + if r < w { + return Some(item); + } + r -= w; + } + items.first() + } + + pub fn add_direct(&mut self, alias: String, target: String) { + if let Some(stripped) = alias.strip_prefix("minecraft:") { + self.aliases.insert(stripped.to_string(), target.clone()); + } else { + self.aliases + .insert(format!("minecraft:{alias}"), target.clone()); + } + self.aliases.insert(alias, target); + } + + pub fn add_random( + &mut self, + alias: String, + targets: &[(String, u32)], + random: &mut RandomGenerator, + ) { + if targets.is_empty() { + return; + } + let total_weight: u32 = targets.iter().map(|(_, w)| *w).sum(); + if total_weight == 0 { + if let Some((target, _)) = targets.first() { + self.add_direct(alias, target.clone()); + } + return; + } + let mut r = random.next_bounded_i32(total_weight as i32) as u32; + for (target, weight) in targets { + if r < *weight { + self.add_direct(alias, target.clone()); + return; + } + r -= *weight; + } + if let Some((target, _)) = targets.first() { + self.add_direct(alias, target.clone()); + } } #[must_use] - pub fn lookup<'a>(&'a self, id: &'a str, random: &mut RandomGenerator) -> &'a str { - if let Some(targets) = self.aliases.get(id) { - if targets.is_empty() { - return id; - } - if targets.len() == 1 { - return &targets[0].0; - } - let total_weight: u32 = targets.iter().map(|(_, w)| *w).sum(); - if total_weight == 0 { - return &targets[0].0; - } - let mut r = random.next_bounded_i32(total_weight as i32) as u32; - for (target, weight) in targets { - if r < *weight { - return target.as_str(); - } - r -= *weight; - } - &targets[0].0 - } else { - id + pub fn lookup<'a>(&'a self, mut id: &'a str, _random: &mut RandomGenerator) -> &'a str { + while let Some(target) = self.aliases.get(id) { + id = target.as_str(); } + id } } diff --git a/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs b/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs index 9b80fe12f..9cb389e05 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs @@ -64,8 +64,16 @@ impl StructureGenerator for NetherFossilGenerator { let x = start_block_x(context.chunk_x) + context.random.next_bounded_i32(16); let z = start_block_z(context.chunk_z) + context.random.next_bounded_i32(16); - let height_range = HEIGHT_MAX - HEIGHT_MIN + 1; - let initial_y = HEIGHT_MIN + context.random.next_bounded_i32(height_range); + let structure = context + .structure_key + .map(|key| pumpkin_data::structures::Structure::get(&key)); + + let initial_y = if let Some(hp) = structure.and_then(|s| s.start_height) { + hp.get(&mut context.random, context.min_y as i8, 256) + } else { + let height_range = HEIGHT_MAX - HEIGHT_MIN + 1; + HEIGHT_MIN + context.random.next_bounded_i32(height_range) + }; // Column scan is deferred to place() since we don't have block data here. // Consume random in vanilla order for determinism. diff --git a/crates/pumpkin-world/src/generation/structure/structures/stronghold/chest_corridor.rs b/crates/pumpkin-world/src/generation/structure/structures/stronghold/chest_corridor.rs index 39681ef29..e3580d7d0 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/stronghold/chest_corridor.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/stronghold/chest_corridor.rs @@ -150,18 +150,16 @@ impl StructurePieceBase for ChestCorridorPiece { inner.add_block(chunk, slab, 2, 1, i, &box_limit); } - // if !self.chest_generated { - // // Check if the target chest position is within the current chunk being processed - // let chest_pos = inner.to_world(3, 2, 3); - // if box_limit.contains(&chest_pos) { - // self.chest_generated = true; - - // let chest_state = Block::CHEST.default_state; - // // Note: In a full implementation, you would use a helper to set the LootTable NBT here - // inner.add_block(chunk, &chest_state, 3, 2, 3, &box_limit); - - // // chunk.set_loot_table(chest_pos, "minecraft:chests/stronghold_corridor"); - // } - // } + if !self.chest_generated { + self.chest_generated = inner.add_chest( + chunk, + &box_limit, + random, + 3, + 2, + 3, + "minecraft:chests/stronghold_corridor", + ); + } } } diff --git a/crates/pumpkin-world/src/generation/structure/structures/stronghold/library.rs b/crates/pumpkin-world/src/generation/structure/structures/stronghold/library.rs index 251abf7b3..ac599f215 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/stronghold/library.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/stronghold/library.rs @@ -73,6 +73,7 @@ impl StructurePieceBase for LibraryPiece { &mut self.piece.piece } + #[expect(clippy::too_many_lines)] fn place( &mut self, chunk: &mut ProtoChunk, @@ -234,10 +235,26 @@ impl StructurePieceBase for LibraryPiece { } // 6. Chests - // inner.add_chest(chunk, &box_limit, random, 3, 3, 5, "stronghold_library"); - // if self.tall { - // inner.add_block(chunk, &Block::AIR.default_state, 12, 9, 1, &box_limit); // Clear space above top chest - // inner.add_chest(chunk, &box_limit, random, 12, 8, 1, "stronghold_library"); - // } + inner.add_chest( + chunk, + &box_limit, + random, + 3, + 3, + 5, + "minecraft:chests/stronghold_library", + ); + if self.tall { + inner.add_block(chunk, Block::AIR.default_state, 12, 9, 1, &box_limit); // Clear space above top chest + inner.add_chest( + chunk, + &box_limit, + random, + 12, + 8, + 1, + "minecraft:chests/stronghold_library", + ); + } } } diff --git a/crates/pumpkin-world/src/generation/structure/structures/stronghold/square_room.rs b/crates/pumpkin-world/src/generation/structure/structures/stronghold/square_room.rs index 096b5d7a0..227577855 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/stronghold/square_room.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/stronghold/square_room.rs @@ -29,8 +29,8 @@ use crate::world::WorldPortalExt; const CROSSING_LOOT_TABLE: &str = "minecraft:chests/stronghold_crossing"; pub struct SquareRoomPiece { - piece: StrongholdPiece, - room_type: u32, + pub piece: StrongholdPiece, + pub room_type: u32, } impl SquareRoomPiece { diff --git a/crates/pumpkin-world/src/generation/structure/template/mod.rs b/crates/pumpkin-world/src/generation/structure/template/mod.rs index a75c0f6f5..87d9610e2 100644 --- a/crates/pumpkin-world/src/generation/structure/template/mod.rs +++ b/crates/pumpkin-world/src/generation/structure/template/mod.rs @@ -46,7 +46,10 @@ pub use cache::{ }; pub use processor::StructureProcessor; pub use pumpkin_data::{BlockState, Mirror as BlockMirror, Rotation as BlockRotation}; -pub use structure_template::{PaletteEntry, StructureTemplate, TemplateBlock, TemplateEntity}; +pub use structure_template::{ + JigsawBlockInfo, Palette, PaletteEntry, SimplePalette, StructureBlockInfo, StructureEntityInfo, + StructurePlaceSettings, StructureTemplate, TemplateBlock, TemplateEntity, +}; pub use template_piece::TemplatePiece; /// Abstraction over block placement, implemented by both [`ProtoChunk`] (worldgen) and @@ -93,7 +96,7 @@ pub fn place_template( ); } -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub fn place_template_with_options( placer: &mut impl BlockPlacer, template: &StructureTemplate, @@ -110,6 +113,9 @@ pub fn place_template_with_options( let world_x = origin.x + rotated_ox; let world_z = origin.z + rotated_oz; + let mut context_rng = LegacyRand::from_seed(hash_block_pos(world_x, origin.y, world_z) as u64); + let mut context = processor::ProcessorContext::new(origin, processors, &mut context_rng); + for block in &template.blocks { let palette_entry = &template.palette[block.state as usize]; @@ -182,8 +188,19 @@ pub fn place_template_with_options( // Apply processors let mut should_place = true; + let mut capped_idx = 0; + let mut rng = + LegacyRand::from_seed(hash_block_pos(world_pos.x, world_pos.y, world_pos.z) as u64); for processor in processors { - let Some(processed_state) = processor.process(placer, world_pos, state) else { + let Some(processed_state) = processor.process_with_context( + placer, + world_pos, + state, + &mut block_entity_nbt, + &mut context, + &mut capped_idx, + &mut rng, + ) else { should_place = false; break; }; @@ -200,23 +217,20 @@ pub fn place_template_with_options( placer.set_block_state(&Vector3::new(wx, wy, wz), state); // Create block entities for interactive blocks (furnaces, chests, etc.) - let block_entity_id = get_block_entity_id(&placed_entry.name); + let final_block = pumpkin_data::Block::from_id(state.id.to_block_id()); + let block_entity_id = get_block_entity_id(final_block.name); if block_entity_nbt.is_some() || block_entity_id.is_some() { - let block_entity_id = block_entity_id.unwrap_or(&placed_entry.name); + let fallback_id = block_entity_id.unwrap_or(final_block.name); let mut placed_nbt = NbtCompound::new(); - placed_nbt.put_string("id", block_entity_id.to_string()); + placed_nbt.put_string("id", fallback_id.to_string()); placed_nbt.put_int("x", wx); placed_nbt.put_int("y", wy); placed_nbt.put_int("z", wz); if let Some(template_nbt) = &block_entity_nbt { for (key, value) in &template_nbt.child_tags { - if key.as_ref() != "x" - && key.as_ref() != "y" - && key.as_ref() != "z" - && key.as_ref() != "id" - { + if key.as_ref() != "x" && key.as_ref() != "y" && key.as_ref() != "z" { placed_nbt.child_tags.insert(key.clone(), value.clone()); } } @@ -301,46 +315,63 @@ pub(crate) fn place_template_entities( /// Returns the block entity ID for blocks that require one, or None if not needed. pub(crate) fn get_block_entity_id(block_name: &str) -> Option<&'static str> { - match block_name { - "minecraft:furnace" => Some("minecraft:furnace"), - "minecraft:chest" => Some("minecraft:chest"), - "minecraft:trapped_chest" => Some("minecraft:trapped_chest"), - "minecraft:barrel" => Some("minecraft:barrel"), - "minecraft:hopper" => Some("minecraft:hopper"), - "minecraft:dropper" => Some("minecraft:dropper"), - "minecraft:dispenser" => Some("minecraft:dispenser"), - "minecraft:brewing_stand" => Some("minecraft:brewing_stand"), - "minecraft:blast_furnace" => Some("minecraft:blast_furnace"), - "minecraft:smoker" => Some("minecraft:smoker"), - "minecraft:shulker_box" => Some("minecraft:shulker_box"), - "minecraft:bed" => Some("minecraft:bed"), - "minecraft:dragon_head" - | "minecraft:dragon_wall_head" - | "minecraft:skeleton_skull" - | "minecraft:skeleton_wall_skull" - | "minecraft:wither_skeleton_skull" - | "minecraft:wither_skeleton_wall_skull" - | "minecraft:zombie_head" - | "minecraft:zombie_wall_head" - | "minecraft:player_head" - | "minecraft:player_wall_head" - | "minecraft:creeper_head" - | "minecraft:creeper_wall_head" - | "minecraft:piglin_head" - | "minecraft:piglin_wall_head" => Some("minecraft:skull"), - "minecraft:sign" - | "minecraft:oak_sign" - | "minecraft:spruce_sign" - | "minecraft:birch_sign" - | "minecraft:jungle_sign" - | "minecraft:acacia_sign" - | "minecraft:dark_oak_sign" - | "minecraft:mangrove_sign" - | "minecraft:cherry_sign" - | "minecraft:bamboo_sign" - | "minecraft:crimson_sign" - | "minecraft:warped_sign" => Some("minecraft:sign"), - "minecraft:hanging_sign" => Some("minecraft:hanging_sign"), + let name = block_name.strip_prefix("minecraft:").unwrap_or(block_name); + match name { + "furnace" => Some("minecraft:furnace"), + "chest" => Some("minecraft:chest"), + "trapped_chest" => Some("minecraft:trapped_chest"), + "barrel" => Some("minecraft:barrel"), + "hopper" => Some("minecraft:hopper"), + "dropper" => Some("minecraft:dropper"), + "dispenser" => Some("minecraft:dispenser"), + "brewing_stand" => Some("minecraft:brewing_stand"), + "blast_furnace" => Some("minecraft:blast_furnace"), + "smoker" => Some("minecraft:smoker"), + "shulker_box" => Some("minecraft:shulker_box"), + "bed" => Some("minecraft:bed"), + "suspicious_sand" | "suspicious_gravel" => Some("minecraft:brushable_block"), + "decorated_pot" => Some("minecraft:decorated_pot"), + "spawner" => Some("minecraft:mob_spawner"), + "trial_spawner" => Some("minecraft:trial_spawner"), + "vault" => Some("minecraft:vault"), + "crafter" => Some("minecraft:crafter"), + "creaking_heart" => Some("minecraft:creaking_heart"), + "chiseled_bookshelf" => Some("minecraft:chiseled_bookshelf"), + "beehive" | "bee_nest" => Some("minecraft:beehive"), + "campfire" | "soul_campfire" => Some("minecraft:campfire"), + "sculk_sensor" | "calibrated_sculk_sensor" => Some("minecraft:sculk_sensor"), + "sculk_catalyst" => Some("minecraft:sculk_catalyst"), + "sculk_shrieker" => Some("minecraft:sculk_shrieker"), + "dragon_head" + | "dragon_wall_head" + | "skeleton_skull" + | "skeleton_wall_skull" + | "wither_skeleton_skull" + | "wither_skeleton_wall_skull" + | "zombie_head" + | "zombie_wall_head" + | "player_head" + | "player_wall_head" + | "creeper_head" + | "creeper_wall_head" + | "piglin_head" + | "piglin_wall_head" => Some("minecraft:skull"), + "sign" | "oak_sign" | "spruce_sign" | "birch_sign" | "jungle_sign" | "acacia_sign" + | "dark_oak_sign" | "mangrove_sign" | "cherry_sign" | "bamboo_sign" | "crimson_sign" + | "warped_sign" | "pale_oak_sign" => Some("minecraft:sign"), + "hanging_sign" + | "oak_hanging_sign" + | "spruce_hanging_sign" + | "birch_hanging_sign" + | "jungle_hanging_sign" + | "acacia_hanging_sign" + | "dark_oak_hanging_sign" + | "mangrove_hanging_sign" + | "cherry_hanging_sign" + | "bamboo_hanging_sign" + | "crimson_hanging_sign" + | "warped_hanging_sign" + | "pale_oak_hanging_sign" => Some("minecraft:hanging_sign"), _ => None, } } diff --git a/crates/pumpkin-world/src/generation/structure/template/processor.rs b/crates/pumpkin-world/src/generation/structure/template/processor.rs index 087c0fa90..bc1159ac6 100644 --- a/crates/pumpkin-world/src/generation/structure/template/processor.rs +++ b/crates/pumpkin-world/src/generation/structure/template/processor.rs @@ -1,81 +1,371 @@ -use pumpkin_data::{Block, BlockId, BlockState, tag}; +use pumpkin_data::{Block, BlockId, BlockState, tag::Taggable}; +use pumpkin_nbt::{compound::NbtCompound, tag::NbtTag}; use pumpkin_util::{ - math::vector3::Vector3, + math::{int_provider::IntProvider, vector3::Vector3}, random::{RandomImpl, hash_block_pos, legacy_rand::LegacyRand}, }; use serde::Deserialize; -use std::sync::{Arc, LazyLock}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; -use super::BlockPlacer; +use super::{BlockPlacer, BlockStateResolver, PaletteEntry}; -#[derive(Clone)] +/// Structure processors are used to dynamically transform blocks in structure templates +/// during world generation or placement. +/// +/// See for specification. +#[derive(Clone, Debug)] pub enum StructureProcessor { + /// Replaces blocks matching rule tests with custom states and block entity modifiers. + Rule(Vec), + /// Randomly removes blocks based on integrity, preserving preexisting world blocks. BlockRot { integrity: f32, - blocks: Option, + rottable_blocks: Option, }, - Rules(Vec), - ProtectedBlocks(BlockTag), + /// Ages stone, cobblestone, stairs, slabs, walls, and obsidian blocks with mossy/cracked variants. + BlockAge { mossiness: f32 }, + /// Removes specified blocks (such as structure void / structure blocks / air), preserving preexisting world blocks. + BlockIgnore(Vec), + /// Modifies the Y-level of blocks relative to terrain heightmap. + Gravity { + heightmap: HeightmapType, + offset: i32, + }, + /// Protects specific world blocks from being overridden by the structure. + ProtectedBlocks(String), + /// Replaces stone variants with blackstone variants and iron bars with chains. + BlackstoneReplace, + /// Replaces jigsaw blocks with their target `final_state`. + JigsawReplacement, + /// Blocks with incomplete outline shapes cannot override lava in the world. + LavaSubmergedBlock, + /// Limits the maximum number of blocks modified by a delegate processor. Capped { - limit: i32, + limit: IntProvider, delegate: Box, }, + /// No-op processor that leaves blocks unchanged. + Nop, } -#[derive(Clone)] -pub struct ProcessorRule { - input: RuleInput, - probability: f32, - output_state: &'static BlockState, -} - -#[derive(Clone, Copy)] -pub enum RuleInput { +#[derive(Clone, Debug)] +pub enum RottableBlocks { + Tag(String), Block(BlockId), - Tag(BlockTag), + List(Vec), } -impl RuleInput { - fn matches(self, block_id: BlockId) -> bool { +impl RottableBlocks { + #[must_use] + pub fn matches(&self, block_id: BlockId) -> bool { match self { - Self::Block(id) => id == block_id, - Self::Tag(tag) => tag.contains(block_id), + Self::Tag(tag) => check_block_has_tag(block_id, tag), + Self::Block(id) => *id == block_id, + Self::List(list) => list.contains(&block_id), } } } -#[derive(Clone, Copy)] -pub enum BlockTag { - AncientCityReplaceable, - FeaturesCannotReplace, - TrailRuinsReplaceable, - Doors, +#[derive(Clone, Debug)] +pub struct IgnoredBlock { + pub block_id: BlockId, + pub properties: Option>, } -impl BlockTag { - fn from_name(name: &str) -> Option { - match name { - "#minecraft:ancient_city_replaceable" | "minecraft:ancient_city_replaceable" => { - Some(Self::AncientCityReplaceable) +impl IgnoredBlock { + #[must_use] + pub fn matches(&self, state: &'static BlockState) -> bool { + if state.id.to_block_id() != self.block_id { + return false; + } + if let Some(props) = &self.properties { + let block = Block::from_id(self.block_id); + if let Some(state_props) = block.properties(state.id) { + let actual = state_props.to_props(); + for (k, v) in props { + if !actual.iter().any(|(ak, av)| ak == k && av == v) { + return false; + } + } + } else { + return false; } - "#minecraft:features_cannot_replace" | "minecraft:features_cannot_replace" => { - Some(Self::FeaturesCannotReplace) + } + true + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HeightmapType { + #[default] + WorldSurfaceWg, + WorldSurface, + OceanFloorWg, + OceanFloor, + MotionBlocking, + MotionBlockingNoLeaves, +} + +#[derive(Clone, Debug)] +pub struct ProcessorRule { + pub position_predicate: PosRuleTest, + pub input_predicate: RuleTest, + pub location_predicate: RuleTest, + pub output_state: &'static BlockState, + pub block_entity_modifier: Option, +} + +#[derive(Clone, Debug)] +pub enum RuleTest { + AlwaysTrue, + BlockMatch(BlockId), + BlockStateMatch(BlockStateMatch), + RandomBlockMatch { + block: BlockId, + probability: f32, + }, + RandomBlockStateMatch { + match_state: BlockStateMatch, + probability: f32, + }, + TagMatch(String), +} + +#[derive(Clone, Debug)] +pub struct BlockStateMatch { + pub block_id: BlockId, + pub properties: Vec<(String, String)>, +} + +impl BlockStateMatch { + #[must_use] + pub fn matches(&self, state: &'static BlockState) -> bool { + if state.id.to_block_id() != self.block_id { + return false; + } + if self.properties.is_empty() { + return true; + } + let block = Block::from_id(self.block_id); + if let Some(state_props) = block.properties(state.id) { + let actual = state_props.to_props(); + for (k, v) in &self.properties { + if !actual.iter().any(|(ak, av)| ak == k && av == v) { + return false; + } } - "#minecraft:trail_ruins_replaceable" | "minecraft:trail_ruins_replaceable" => { - Some(Self::TrailRuinsReplaceable) - } - "#minecraft:doors" | "minecraft:doors" => Some(Self::Doors), - _ => None, + true + } else { + false } } +} - fn contains(self, block_id: BlockId) -> bool { - block_id.has_tag(match self { - Self::AncientCityReplaceable => tag::Block::MINECRAFT_ANCIENT_CITY_REPLACEABLE, - Self::FeaturesCannotReplace => tag::Block::MINECRAFT_FEATURES_CANNOT_REPLACE, - Self::TrailRuinsReplaceable => tag::Block::MINECRAFT_TRAIL_RUINS_REPLACEABLE, - Self::Doors => tag::Block::MINECRAFT_DOORS, - }) +impl RuleTest { + #[must_use] + pub fn test(&self, state: &'static BlockState, rng: &mut impl RandomImpl) -> bool { + match self { + Self::AlwaysTrue => true, + Self::BlockMatch(block_id) => state.id.to_block_id() == *block_id, + Self::BlockStateMatch(matcher) => matcher.matches(state), + Self::RandomBlockMatch { block, probability } => { + state.id.to_block_id() == *block && rng.next_f32() < *probability + } + Self::RandomBlockStateMatch { + match_state, + probability, + } => match_state.matches(state) && rng.next_f32() < *probability, + Self::TagMatch(tag) => check_block_has_tag(state.id.to_block_id(), tag), + } + } +} + +#[derive(Clone, Debug, Default)] +pub enum PosRuleTest { + #[default] + AlwaysTrue, + LinearPos { + min_dist: i32, + max_dist: i32, + min_chance: f32, + max_chance: f32, + }, + AxisAlignedLinearPos { + axis: Axis, + min_dist: i32, + max_dist: i32, + min_chance: f32, + max_chance: f32, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Axis { + X, + #[default] + Y, + Z, +} + +impl PosRuleTest { + #[must_use] + pub fn test( + &self, + pos: Vector3, + structure_start: Vector3, + rng: &mut impl RandomImpl, + ) -> bool { + match self { + Self::AlwaysTrue => true, + Self::LinearPos { + min_dist, + max_dist, + min_chance, + max_chance, + } => { + let dx = (pos.x - structure_start.x).abs(); + let dy = (pos.y - structure_start.y).abs(); + let dz = (pos.z - structure_start.z).abs(); + let dist = dx + dy + dz; + let chance = + calculate_linear_chance(dist, *min_dist, *max_dist, *min_chance, *max_chance); + rng.next_f32() < chance + } + Self::AxisAlignedLinearPos { + axis, + min_dist, + max_dist, + min_chance, + max_chance, + } => { + let dist = match axis { + Axis::X => (pos.x - structure_start.x).abs(), + Axis::Y => (pos.y - structure_start.y).abs(), + Axis::Z => (pos.z - structure_start.z).abs(), + }; + let chance = + calculate_linear_chance(dist, *min_dist, *max_dist, *min_chance, *max_chance); + rng.next_f32() < chance + } + } + } +} + +fn calculate_linear_chance( + dist: i32, + min_dist: i32, + max_dist: i32, + min_chance: f32, + max_chance: f32, +) -> f32 { + if max_dist <= min_dist { + if dist <= min_dist { + min_chance.clamp(0.0, 1.0) + } else { + max_chance.clamp(0.0, 1.0) + } + } else if dist <= min_dist { + min_chance.clamp(0.0, 1.0) + } else if dist >= max_dist { + max_chance.clamp(0.0, 1.0) + } else { + let t = (dist - min_dist) as f32 / (max_dist - min_dist) as f32; + (t * (max_chance - min_chance) + min_chance).clamp(0.0, 1.0) + } +} + +#[derive(Clone, Debug)] +pub enum BlockEntityModifier { + AppendLoot { loot_table: String }, + AppendStatic { data: NbtCompound }, + Clear, + Passthrough, +} + +impl BlockEntityModifier { + pub fn apply( + &self, + nbt: &mut Option, + _pos: Vector3, + rng: &mut impl RandomImpl, + ) { + match self { + Self::AppendLoot { loot_table } => { + let compound = nbt.get_or_insert_with(NbtCompound::new); + compound.put_string("LootTable", loot_table.clone()); + compound.put_long("LootTableSeed", rng.next_i64()); + } + Self::AppendStatic { data } => { + let compound = nbt.get_or_insert_with(NbtCompound::new); + for (k, v) in &data.child_tags { + compound.child_tags.insert(k.clone(), v.clone()); + } + } + Self::Clear => { + *nbt = None; + } + Self::Passthrough => {} + } + } +} + +#[must_use] +pub fn check_block_has_tag(block_id: BlockId, tag: &str) -> bool { + let raw_tag = tag.strip_prefix('#').unwrap_or(tag); + let block = Block::from_id(block_id); + if block.is_tagged_with(raw_tag).unwrap_or(false) { + return true; + } + if !raw_tag.contains(':') { + let with_namespace = format!("minecraft:{raw_tag}"); + if block.is_tagged_with(&with_namespace).unwrap_or(false) { + return true; + } + } + false +} + +/// Execution context passed during structure template processing. +#[derive(Clone, Debug, Default)] +pub struct ProcessorContext { + pub structure_start: Vector3, + pub capped_limits: Vec, + pub capped_counts: Vec, +} + +impl ProcessorContext { + #[must_use] + pub fn new( + structure_start: Vector3, + processors: &[StructureProcessor], + rng: &mut impl RandomImpl, + ) -> Self { + let mut capped_limits = Vec::new(); + let mut capped_counts = Vec::new(); + collect_capped_limits(processors, &mut capped_limits, &mut capped_counts, rng); + Self { + structure_start, + capped_limits, + capped_counts, + } + } +} + +fn collect_capped_limits( + processors: &[StructureProcessor], + limits: &mut Vec, + counts: &mut Vec, + rng: &mut impl RandomImpl, +) { + for processor in processors { + if let StructureProcessor::Capped { limit, delegate } = processor { + limits.push(limit.get(rng)); + counts.push(0); + collect_capped_limits(std::slice::from_ref(delegate.as_ref()), limits, counts, rng); + } } } @@ -84,130 +374,707 @@ impl StructureProcessor { pub fn process( &self, placer: &impl BlockPlacer, - pos: Vector3, + world_pos: Vector3, state: &'static BlockState, ) -> Option<&'static BlockState> { - let input_block = state.id.to_block_id(); + let mut nbt = None; + let mut context = ProcessorContext::default(); + let mut capped_idx = 0; + let mut rng = + LegacyRand::from_seed(hash_block_pos(world_pos.x, world_pos.y, world_pos.z) as u64); + self.process_with_context( + placer, + world_pos, + state, + &mut nbt, + &mut context, + &mut capped_idx, + &mut rng, + ) + } + + #[must_use] + #[expect(clippy::too_many_arguments)] + pub fn process_with_context( + &self, + placer: &impl BlockPlacer, + world_pos: Vector3, + state: &'static BlockState, + nbt: &mut Option, + context: &mut ProcessorContext, + capped_idx: &mut usize, + rng: &mut impl RandomImpl, + ) -> Option<&'static BlockState> { match self { - Self::BlockRot { integrity, blocks } => { - if blocks.is_some_and(|blocks| !blocks.contains(input_block)) { + Self::Rule(rules) => { + let world_state_id = placer.get_block_state(&world_pos); + let world_state = BlockState::from_id(world_state_id); + for rule in rules { + if rule + .position_predicate + .test(world_pos, context.structure_start, rng) + && rule.input_predicate.test(state, rng) + && rule.location_predicate.test(world_state, rng) + { + if let Some(modifier) = &rule.block_entity_modifier { + modifier.apply(nbt, world_pos, rng); + } + return Some(rule.output_state); + } + } + Some(state) + } + Self::BlockRot { + integrity, + rottable_blocks, + } => { + let input_block = state.id.to_block_id(); + if let Some(rottable) = rottable_blocks + && !rottable.matches(input_block) + { return Some(state); } - let mut random = LegacyRand::from_seed(hash_block_pos(pos.x, pos.y, pos.z) as u64); - (random.next_f32() <= *integrity).then_some(state) + (rng.next_f32() <= *integrity).then_some(state) } - Self::Rules(rules) => { - let mut random = LegacyRand::from_seed(hash_block_pos(pos.x, pos.y, pos.z) as u64); - rules - .iter() - .find(|rule| { - rule.input.matches(input_block) && random.next_f32() < rule.probability - }) - .map_or(Some(state), |rule| Some(rule.output_state)) + Self::BlockAge { mossiness } => Some(process_block_age(state, *mossiness, rng)), + Self::BlockIgnore(ignored_blocks) => { + if ignored_blocks.iter().any(|ignored| ignored.matches(state)) { + None + } else { + Some(state) + } } - Self::ProtectedBlocks(blocks) => { - let existing = placer.get_block_state(&pos).to_block_id(); - (!blocks.contains(existing)).then_some(state) + Self::Gravity { .. } | Self::Nop => Some(state), + Self::ProtectedBlocks(tag) => { + let world_state_id = placer.get_block_state(&world_pos); + if check_block_has_tag(world_state_id.to_block_id(), tag) { + None + } else { + Some(state) + } + } + Self::BlackstoneReplace => Some(process_blackstone_replace(state)), + Self::JigsawReplacement => { + if state.id.to_block_id() == BlockId::JIGSAW { + let final_state_str = nbt + .as_ref() + .and_then(|n| n.get_string("final_state")) + .map(str::to_string); + *nbt = None; + let final_str = final_state_str.as_deref().unwrap_or("minecraft:air"); + let palette_entry = PaletteEntry::from_string(final_str); + BlockStateResolver::resolve_simple(&palette_entry) + .or(Some(Block::AIR.default_state)) + } else { + Some(state) + } + } + Self::LavaSubmergedBlock => { + let world_state_id = placer.get_block_state(&world_pos); + if world_state_id.to_block_id() == BlockId::LAVA + && (!state.is_full_cube() || !state.is_solid_block()) + { + return None; + } + Some(state) + } + Self::Capped { delegate, .. } => { + let idx = *capped_idx; + *capped_idx += 1; + if idx < context.capped_limits.len() { + let limit = context.capped_limits[idx]; + let count = context.capped_counts[idx]; + if count < limit { + let prev_state = state; + let prev_nbt = nbt.clone(); + let res = delegate.process_with_context( + placer, world_pos, state, nbt, context, capped_idx, rng, + ); + if res != Some(prev_state) || *nbt != prev_nbt { + context.capped_counts[idx] += 1; + } + return res; + } + Some(state) + } else { + delegate.process_with_context( + placer, world_pos, state, nbt, context, capped_idx, rng, + ) + } } - Self::Capped { limit: _, delegate } => delegate.process(placer, pos, state), } } } -#[derive(Deserialize)] -struct RawProcessorList { - processors: Vec, +fn replace_preserving_properties( + old_state: &'static BlockState, + target_block: &'static Block, +) -> &'static BlockState { + let old_block = Block::from_id(old_state.id.to_block_id()); + old_block + .properties(old_state.id) + .map_or(target_block.default_state, |props_source| { + let props: Vec<(&str, &str)> = props_source + .to_props() + .iter() + .map(|(k, v)| (*k, *v)) + .collect(); + let new_state_id = target_block + .from_properties(&props) + .to_state_id(target_block); + BlockState::from_id(new_state_id) + }) } -#[derive(Deserialize)] +fn process_block_age( + state: &'static BlockState, + mossiness: f32, + rng: &mut impl RandomImpl, +) -> &'static BlockState { + let block_id = state.id.to_block_id(); + + if matches!( + block_id, + BlockId::STONE_BRICKS + | BlockId::STONE + | BlockId::CRACKED_STONE_BRICKS + | BlockId::CHISELED_STONE_BRICKS + | BlockId::INFESTED_STONE + | BlockId::INFESTED_STONE_BRICKS + | BlockId::INFESTED_CRACKED_STONE_BRICKS + | BlockId::INFESTED_CHISELED_STONE_BRICKS + ) { + if rng.next_f32() < 0.5 { + if rng.next_f32() < mossiness { + return Block::MOSSY_STONE_BRICKS.default_state; + } + return Block::CRACKED_STONE_BRICKS.default_state; + } + } else if block_id == BlockId::STONE_BRICK_STAIRS + || block_id == BlockId::INFESTED_MOSSY_STONE_BRICKS + { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_STONE_BRICK_STAIRS); + } + } else if block_id == BlockId::STONE_BRICK_SLAB { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_STONE_BRICK_SLAB); + } + } else if block_id == BlockId::STONE_BRICK_WALL { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_STONE_BRICK_WALL); + } + } else if block_id == BlockId::COBBLESTONE_STAIRS { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_COBBLESTONE_STAIRS); + } + } else if block_id == BlockId::COBBLESTONE_SLAB { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_COBBLESTONE_SLAB); + } + } else if block_id == BlockId::COBBLESTONE_WALL { + if rng.next_f32() < 0.5 && rng.next_f32() < mossiness { + return replace_preserving_properties(state, &Block::MOSSY_COBBLESTONE_WALL); + } + } else if block_id == BlockId::OBSIDIAN && rng.next_f32() < 0.15 { + return Block::CRYING_OBSIDIAN.default_state; + } + + state +} + +fn process_blackstone_replace(state: &'static BlockState) -> &'static BlockState { + let block_id = state.id.to_block_id(); + match block_id { + BlockId::COBBLESTONE | BlockId::MOSSY_COBBLESTONE | BlockId::INFESTED_COBBLESTONE => { + Block::BLACKSTONE.default_state + } + BlockId::STONE | BlockId::INFESTED_STONE => Block::POLISHED_BLACKSTONE.default_state, + BlockId::STONE_BRICKS + | BlockId::MOSSY_STONE_BRICKS + | BlockId::INFESTED_STONE_BRICKS + | BlockId::INFESTED_MOSSY_STONE_BRICKS => Block::POLISHED_BLACKSTONE_BRICKS.default_state, + BlockId::CRACKED_STONE_BRICKS | BlockId::INFESTED_CRACKED_STONE_BRICKS => { + Block::CRACKED_POLISHED_BLACKSTONE_BRICKS.default_state + } + BlockId::CHISELED_STONE_BRICKS | BlockId::INFESTED_CHISELED_STONE_BRICKS => { + Block::CHISELED_POLISHED_BLACKSTONE.default_state + } + BlockId::STONE_BRICK_STAIRS | BlockId::MOSSY_STONE_BRICK_STAIRS => { + replace_preserving_properties(state, &Block::POLISHED_BLACKSTONE_BRICK_STAIRS) + } + BlockId::COBBLESTONE_STAIRS | BlockId::MOSSY_COBBLESTONE_STAIRS | BlockId::STONE_STAIRS => { + replace_preserving_properties(state, &Block::BLACKSTONE_STAIRS) + } + BlockId::STONE_BRICK_SLAB | BlockId::MOSSY_STONE_BRICK_SLAB => { + replace_preserving_properties(state, &Block::POLISHED_BLACKSTONE_BRICK_SLAB) + } + BlockId::COBBLESTONE_SLAB | BlockId::MOSSY_COBBLESTONE_SLAB | BlockId::STONE_SLAB => { + replace_preserving_properties(state, &Block::BLACKSTONE_SLAB) + } + BlockId::STONE_BRICK_WALL | BlockId::MOSSY_STONE_BRICK_WALL => { + replace_preserving_properties(state, &Block::POLISHED_BLACKSTONE_BRICK_WALL) + } + BlockId::COBBLESTONE_WALL | BlockId::MOSSY_COBBLESTONE_WALL => { + replace_preserving_properties(state, &Block::BLACKSTONE_WALL) + } + BlockId::IRON_BARS => replace_preserving_properties(state, &Block::IRON_CHAIN), + _ => state, + } +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum RawProcessorListWrapper { + Object { processors: Vec }, + Array(Vec), +} + +#[derive(Deserialize, Debug)] #[serde(tag = "processor_type")] enum RawProcessor { - #[serde(rename = "minecraft:block_rot")] + #[serde(rename = "minecraft:rule", alias = "rule")] + Rule { rules: Vec }, + #[serde(rename = "minecraft:block_rot", alias = "block_rot")] BlockRot { integrity: f32, - rottable_blocks: Option, + #[serde(default)] + rottable_blocks: Option, }, - #[serde(rename = "minecraft:rule")] - Rule { rules: Vec }, - #[serde(rename = "minecraft:protected_blocks")] + #[serde(rename = "minecraft:block_age", alias = "block_age")] + BlockAge { mossiness: f32 }, + #[serde(rename = "minecraft:block_ignore", alias = "block_ignore")] + BlockIgnore { blocks: Vec }, + #[serde(rename = "minecraft:gravity", alias = "gravity")] + Gravity { + #[serde(default = "default_gravity_heightmap")] + heightmap: String, + #[serde(default)] + offset: i32, + }, + #[serde(rename = "minecraft:protected_blocks", alias = "protected_blocks")] ProtectedBlocks { value: String }, - #[serde(rename = "minecraft:capped")] - Capped { limit: i32, delegate: Box }, + #[serde(rename = "minecraft:blackstone_replace", alias = "blackstone_replace")] + BlackstoneReplace, + #[serde(rename = "minecraft:jigsaw_replacement", alias = "jigsaw_replacement")] + JigsawReplacement, + #[serde( + rename = "minecraft:lava_submerged_block", + alias = "lava_submerged_block" + )] + LavaSubmergedBlock, + #[serde(rename = "minecraft:capped", alias = "capped")] + Capped { + #[serde(alias = "value")] + limit: IntProvider, + delegate: Box, + }, + #[serde(rename = "minecraft:nop", alias = "nop")] + Nop, } -#[derive(Deserialize)] +fn default_gravity_heightmap() -> String { + "WORLD_SURFACE_WG".to_string() +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum RawRottableBlocks { + Single(String), + List(Vec), +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum RawBlockStateOrName { + State(RawOutputState), + Name(String), +} + +#[derive(Deserialize, Debug)] struct RawRule { - input_predicate: RawInputPredicate, + #[serde(default)] + position_predicate: Option, + input_predicate: RawRuleTest, + #[serde(default)] + location_predicate: Option, output_state: RawOutputState, + #[serde(default)] + block_entity_modifier: Option, } -#[derive(Deserialize)] -struct RawInputPredicate { - block: Option, - tag: Option, - probability: Option, -} - -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct RawOutputState { #[serde(rename = "Name")] name: String, + #[serde(rename = "Properties", default)] + properties: HashMap, } +#[derive(Deserialize, Debug)] +#[serde(tag = "predicate_type")] +enum RawRuleTest { + #[serde(rename = "minecraft:always_true", alias = "always_true")] + AlwaysTrue, + #[serde(rename = "minecraft:block_match", alias = "block_match")] + BlockMatch { block: String }, + #[serde(rename = "minecraft:blockstate_match", alias = "blockstate_match")] + BlockStateMatch { + #[serde(default)] + block: Option, + #[serde(default)] + block_state: Option, + }, + #[serde(rename = "minecraft:random_block_match", alias = "random_block_match")] + RandomBlockMatch { block: String, probability: f32 }, + #[serde( + rename = "minecraft:random_blockstate_match", + alias = "random_blockstate_match" + )] + RandomBlockStateMatch { + #[serde(default)] + block: Option, + #[serde(default)] + block_state: Option, + probability: f32, + }, + #[serde(rename = "minecraft:tag_match", alias = "tag_match")] + TagMatch { tag: String }, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "predicate_type")] +enum RawPosRuleTest { + #[serde(rename = "minecraft:always_true", alias = "always_true")] + AlwaysTrue, + #[serde(rename = "minecraft:linear_pos", alias = "linear_pos")] + LinearPos { + #[serde(default)] + min_dist: i32, + #[serde(default)] + max_dist: i32, + #[serde(default)] + min_chance: f32, + #[serde(default)] + max_chance: f32, + }, + #[serde( + rename = "minecraft:axis_aligned_linear_pos", + alias = "axis_aligned_linear_pos" + )] + AxisAlignedLinearPos { + #[serde(default = "default_axis")] + axis: String, + #[serde(default)] + min_dist: i32, + #[serde(default)] + max_dist: i32, + #[serde(default)] + min_chance: f32, + #[serde(default)] + max_chance: f32, + }, +} + +fn default_axis() -> String { + "y".to_string() +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +enum RawBlockEntityModifier { + #[serde(rename = "minecraft:append_loot", alias = "append_loot")] + AppendLoot { loot_table: String }, + #[serde(rename = "minecraft:append_static", alias = "append_static")] + AppendStatic { data: serde_json::Value }, + #[serde(rename = "minecraft:clear", alias = "clear")] + Clear, + #[serde(rename = "minecraft:passthrough", alias = "passthrough")] + Passthrough, +} + +fn resolve_output_state(raw: &RawOutputState) -> Option<&'static BlockState> { + let name = raw.name.strip_prefix("minecraft:").unwrap_or(&raw.name); + let block = Block::from_name(name).or_else(|| Block::from_registry_key(name))?; + if raw.properties.is_empty() { + Some(block.default_state) + } else { + let props_vec: Vec<(&str, &str)> = raw + .properties + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let state_id = block.from_properties(&props_vec).to_state_id(block); + Some(BlockState::from_id(state_id)) + } +} + +fn json_to_nbt(value: serde_json::Value) -> NbtTag { + match value { + serde_json::Value::Null => NbtTag::Byte(0), + serde_json::Value::Bool(b) => NbtTag::Byte(i8::from(b)), + serde_json::Value::Number(num) => num.as_i64().map_or_else( + || num.as_f64().map_or(NbtTag::Int(0), NbtTag::Double), + |i| i32::try_from(i).map_or(NbtTag::Long(i), NbtTag::Int), + ), + serde_json::Value::String(s) => NbtTag::String(s.into_boxed_str()), + serde_json::Value::Array(arr) => { + let tags: Vec = arr.into_iter().map(json_to_nbt).collect(); + NbtTag::List(tags) + } + serde_json::Value::Object(map) => { + let mut compound = NbtCompound::new(); + for (k, v) in map { + compound.child_tags.insert(k.into(), json_to_nbt(v)); + } + NbtTag::Compound(compound) + } + } +} + +fn convert_raw_rule_test(raw: RawRuleTest) -> Option { + match raw { + RawRuleTest::AlwaysTrue => Some(RuleTest::AlwaysTrue), + RawRuleTest::BlockMatch { block } => { + let block_name = block.strip_prefix("minecraft:").unwrap_or(&block); + let block_obj = + Block::from_name(block_name).or_else(|| Block::from_registry_key(block_name))?; + Some(RuleTest::BlockMatch(block_obj.id)) + } + RawRuleTest::BlockStateMatch { block, block_state } => { + if let Some(raw_state) = block_state { + let name = raw_state + .name + .strip_prefix("minecraft:") + .unwrap_or(&raw_state.name); + let block_obj = + Block::from_name(name).or_else(|| Block::from_registry_key(name))?; + Some(RuleTest::BlockStateMatch(BlockStateMatch { + block_id: block_obj.id, + properties: raw_state.properties.into_iter().collect(), + })) + } else if let Some(b) = block { + let block_name = b.strip_prefix("minecraft:").unwrap_or(&b); + let block_obj = Block::from_name(block_name) + .or_else(|| Block::from_registry_key(block_name))?; + Some(RuleTest::BlockMatch(block_obj.id)) + } else { + None + } + } + RawRuleTest::RandomBlockMatch { block, probability } => { + let block_name = block.strip_prefix("minecraft:").unwrap_or(&block); + let block_obj = + Block::from_name(block_name).or_else(|| Block::from_registry_key(block_name))?; + Some(RuleTest::RandomBlockMatch { + block: block_obj.id, + probability, + }) + } + RawRuleTest::RandomBlockStateMatch { + block, + block_state, + probability, + } => { + if let Some(raw_state) = block_state { + let name = raw_state + .name + .strip_prefix("minecraft:") + .unwrap_or(&raw_state.name); + let block_obj = + Block::from_name(name).or_else(|| Block::from_registry_key(name))?; + Some(RuleTest::RandomBlockStateMatch { + match_state: BlockStateMatch { + block_id: block_obj.id, + properties: raw_state.properties.into_iter().collect(), + }, + probability, + }) + } else if let Some(b) = block { + let block_name = b.strip_prefix("minecraft:").unwrap_or(&b); + let block_obj = Block::from_name(block_name) + .or_else(|| Block::from_registry_key(block_name))?; + Some(RuleTest::RandomBlockMatch { + block: block_obj.id, + probability, + }) + } else { + None + } + } + RawRuleTest::TagMatch { tag } => Some(RuleTest::TagMatch(tag)), + } +} + +fn convert_raw_pos_rule_test(raw: Option) -> PosRuleTest { + match raw { + None | Some(RawPosRuleTest::AlwaysTrue) => PosRuleTest::AlwaysTrue, + Some(RawPosRuleTest::LinearPos { + min_dist, + max_dist, + min_chance, + max_chance, + }) => PosRuleTest::LinearPos { + min_dist, + max_dist, + min_chance, + max_chance, + }, + Some(RawPosRuleTest::AxisAlignedLinearPos { + axis, + min_dist, + max_dist, + min_chance, + max_chance, + }) => { + let axis = match axis.to_lowercase().as_str() { + "x" => Axis::X, + "z" => Axis::Z, + _ => Axis::Y, + }; + PosRuleTest::AxisAlignedLinearPos { + axis, + min_dist, + max_dist, + min_chance, + max_chance, + } + } + } +} + +#[expect(clippy::too_many_lines)] fn convert_raw_processor(raw: RawProcessor) -> Option { match raw { RawProcessor::BlockRot { integrity, rottable_blocks, - } => match rottable_blocks { - Some(blocks) => Some(StructureProcessor::BlockRot { + } => { + let rottable = rottable_blocks.and_then(|r| match r { + RawRottableBlocks::Single(s) => { + if s.starts_with('#') { + Some(RottableBlocks::Tag(s)) + } else { + let name = s.strip_prefix("minecraft:").unwrap_or(&s); + Block::from_name(name) + .or_else(|| Block::from_registry_key(name)) + .map(|b| RottableBlocks::Block(b.id)) + .or(Some(RottableBlocks::Tag(s))) + } + } + RawRottableBlocks::List(list) => { + let ids: Vec = list + .into_iter() + .filter_map(|s| { + let name = s.strip_prefix("minecraft:").unwrap_or(&s); + Block::from_name(name) + .or_else(|| Block::from_registry_key(name)) + .map(|b| b.id) + }) + .collect(); + Some(RottableBlocks::List(ids)) + } + }); + Some(StructureProcessor::BlockRot { integrity, - blocks: Some(BlockTag::from_name(&blocks)?), - }), - None => Some(StructureProcessor::BlockRot { - integrity, - blocks: None, - }), - }, - RawProcessor::ProtectedBlocks { value } => { - BlockTag::from_name(&value).map(StructureProcessor::ProtectedBlocks) + rottable_blocks: rottable, + }) } - RawProcessor::Rule { rules } => Some(StructureProcessor::Rules( - rules + RawProcessor::BlockAge { mossiness } => Some(StructureProcessor::BlockAge { mossiness }), + RawProcessor::BlockIgnore { blocks } => { + let ignored: Vec = blocks + .into_iter() + .filter_map(|b| match b { + RawBlockStateOrName::State(raw_state) => { + let name = raw_state + .name + .strip_prefix("minecraft:") + .unwrap_or(&raw_state.name); + let block = + Block::from_name(name).or_else(|| Block::from_registry_key(name))?; + Some(IgnoredBlock { + block_id: block.id, + properties: if raw_state.properties.is_empty() { + None + } else { + Some(raw_state.properties.into_iter().collect()) + }, + }) + } + RawBlockStateOrName::Name(name_str) => { + let name = name_str.strip_prefix("minecraft:").unwrap_or(&name_str); + let block = + Block::from_name(name).or_else(|| Block::from_registry_key(name))?; + Some(IgnoredBlock { + block_id: block.id, + properties: None, + }) + } + }) + .collect(); + Some(StructureProcessor::BlockIgnore(ignored)) + } + RawProcessor::Gravity { heightmap, offset } => { + let heightmap = match heightmap.as_str() { + "WORLD_SURFACE" => HeightmapType::WorldSurface, + "OCEAN_FLOOR_WG" => HeightmapType::OceanFloorWg, + "OCEAN_FLOOR" => HeightmapType::OceanFloor, + "MOTION_BLOCKING" => HeightmapType::MotionBlocking, + "MOTION_BLOCKING_NO_LEAVES" => HeightmapType::MotionBlockingNoLeaves, + _ => HeightmapType::WorldSurfaceWg, + }; + Some(StructureProcessor::Gravity { heightmap, offset }) + } + RawProcessor::ProtectedBlocks { value } => Some(StructureProcessor::ProtectedBlocks(value)), + RawProcessor::BlackstoneReplace => Some(StructureProcessor::BlackstoneReplace), + RawProcessor::JigsawReplacement => Some(StructureProcessor::JigsawReplacement), + RawProcessor::LavaSubmergedBlock => Some(StructureProcessor::LavaSubmergedBlock), + RawProcessor::Rule { rules } => { + let converted_rules: Vec = rules .into_iter() .filter_map(|rule| { - let output_name = rule - .output_state - .name - .strip_prefix("minecraft:") - .unwrap_or(&rule.output_state.name); - let output_block = Block::from_name(output_name)?; - - let input = if let Some(ref block_name) = rule.input_predicate.block { - let input_name = - block_name.strip_prefix("minecraft:").unwrap_or(block_name); - let input_block = Block::from_name(input_name)?; - RuleInput::Block(input_block.id) - } else { - let tag_name = rule.input_predicate.tag.as_ref()?; - let tag = BlockTag::from_name(tag_name)?; - RuleInput::Tag(tag) - }; + let output_state = resolve_output_state(&rule.output_state)?; + let input_predicate = convert_raw_rule_test(rule.input_predicate)?; + let location_predicate = rule + .location_predicate + .and_then(convert_raw_rule_test) + .unwrap_or(RuleTest::AlwaysTrue); + let position_predicate = convert_raw_pos_rule_test(rule.position_predicate); + let block_entity_modifier = rule.block_entity_modifier.map(|m| match m { + RawBlockEntityModifier::AppendLoot { loot_table } => { + BlockEntityModifier::AppendLoot { loot_table } + } + RawBlockEntityModifier::AppendStatic { data } => { + if let NbtTag::Compound(compound) = json_to_nbt(data) { + BlockEntityModifier::AppendStatic { data: compound } + } else { + BlockEntityModifier::AppendStatic { + data: NbtCompound::new(), + } + } + } + RawBlockEntityModifier::Clear => BlockEntityModifier::Clear, + RawBlockEntityModifier::Passthrough => BlockEntityModifier::Passthrough, + }); Some(ProcessorRule { - input, - probability: rule.input_predicate.probability.unwrap_or(1.0), - output_state: output_block.default_state, + position_predicate, + input_predicate, + location_predicate, + output_state, + block_entity_modifier, }) }) - .collect(), - )), + .collect(); + Some(StructureProcessor::Rule(converted_rules)) + } RawProcessor::Capped { limit, delegate } => { convert_raw_processor(*delegate).map(|proc| StructureProcessor::Capped { limit, delegate: Box::new(proc), }) } + RawProcessor::Nop => Some(StructureProcessor::Nop), } } @@ -216,7 +1083,9 @@ pub fn load_processor_list(name: &str) -> Arc<[StructureProcessor]> { static CACHE: LazyLock>> = LazyLock::new(dashmap::DashMap::new); - if let Some(processors) = CACHE.get(name) { + let name_key = name.strip_prefix("minecraft:").unwrap_or(name); + + if let Some(processors) = CACHE.get(name_key) { return Arc::clone(&processors); } @@ -224,20 +1093,22 @@ pub fn load_processor_list(name: &str) -> Arc<[StructureProcessor]> { tracing::warn!("Unknown structure processor list: {name}"); return Arc::from([]); }; - let raw: RawProcessorList = match serde_json::from_str(json) { - Ok(raw) => raw, + let raw_list: Vec = match serde_json::from_str::(json) { + Ok( + RawProcessorListWrapper::Object { processors } + | RawProcessorListWrapper::Array(processors), + ) => processors, Err(error) => { tracing::error!("Failed to parse structure processor list {name}: {error}"); return Arc::from([]); } }; - let processors = raw - .processors + let processors = raw_list .into_iter() .filter_map(convert_raw_processor) .collect::>(); - CACHE.insert(name.to_owned(), Arc::clone(&processors)); + CACHE.insert(name_key.to_owned(), Arc::clone(&processors)); processors } @@ -282,7 +1153,7 @@ mod tests { processors.as_ref(), [StructureProcessor::BlockRot { integrity, - blocks: None, + rottable_blocks: None, }] if (*integrity - 0.05).abs() < f32::EPSILON )); } diff --git a/crates/pumpkin-world/src/generation/structure/template/structure_template.rs b/crates/pumpkin-world/src/generation/structure/template/structure_template.rs index 6b7bf55da..0d58b7dbb 100644 --- a/crates/pumpkin-world/src/generation/structure/template/structure_template.rs +++ b/crates/pumpkin-world/src/generation/structure/template/structure_template.rs @@ -1,15 +1,19 @@ -//! Structure template loading from NBT files. +//! Structure template loading, transformation, and saving (1:1 matching Minecraft vanilla `StructureTemplate`). //! -//! This module handles parsing vanilla Minecraft structure template files (.nbt) -//! into a runtime representation that can be placed in the world. +//! This module handles parsing vanilla Minecraft structure template files (`.nbt`) +//! into a runtime representation with palettes, entities, block info, and transformations. use std::io::Cursor; +use pumpkin_data::{Mirror, Rotation}; use pumpkin_nbt::{compound::NbtCompound, nbt_compress::read_gzip_compound_tag, tag::NbtTag}; -use pumpkin_util::math::vector3::Vector3; +use pumpkin_util::math::{block_box::BlockBox, vector3::Vector3}; use thiserror::Error; -/// Errors that can occur when loading a structure template. +use super::processor::StructureProcessor; +use crate::generation::structure::structures::jigsaw::JigsawJointType; + +/// Errors that can occur when loading or saving a structure template. #[derive(Debug, Error)] pub enum TemplateError { #[error("Failed to decompress NBT: {0}")] @@ -25,30 +29,180 @@ pub enum TemplateError { InvalidPaletteIndex(u32), } -/// A loaded structure template from an NBT file. -/// -/// Structure templates contain a palette of block states and a list of blocks -/// referencing that palette. This allows for efficient storage and the ability -/// to transform (rotate/mirror) the template when placing. -#[derive(Debug, Clone)] +/// Settings used when placing, transforming, or querying a [`StructureTemplate`]. +#[derive(Clone, Debug)] +pub struct StructurePlaceSettings { + pub mirror: Mirror, + pub rotation: Rotation, + pub rotation_pivot: Vector3, + pub bounding_box: Option, + pub ignore_entities: bool, + pub apply_waterlogging: bool, + pub known_shape: bool, + pub processors: Vec, + pub finalize_entities: bool, + pub palette_index: Option, +} + +impl Default for StructurePlaceSettings { + fn default() -> Self { + Self { + mirror: Mirror::None, + rotation: Rotation::None, + rotation_pivot: Vector3::new(0, 0, 0), + bounding_box: None, + ignore_entities: false, + apply_waterlogging: false, + known_shape: false, + processors: Vec::new(), + finalize_entities: false, + palette_index: None, + } + } +} + +impl StructurePlaceSettings { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub const fn set_mirror(mut self, mirror: Mirror) -> Self { + self.mirror = mirror; + self + } + + #[must_use] + pub const fn set_rotation(mut self, rotation: Rotation) -> Self { + self.rotation = rotation; + self + } + + #[must_use] + pub const fn set_rotation_pivot(mut self, pivot: Vector3) -> Self { + self.rotation_pivot = pivot; + self + } + + #[must_use] + pub const fn set_bounding_box(mut self, bounding_box: Option) -> Self { + self.bounding_box = bounding_box; + self + } + + #[must_use] + pub const fn set_ignore_entities(mut self, ignore_entities: bool) -> Self { + self.ignore_entities = ignore_entities; + self + } + + #[must_use] + pub const fn set_apply_waterlogging(mut self, apply_waterlogging: bool) -> Self { + self.apply_waterlogging = apply_waterlogging; + self + } + + #[must_use] + pub const fn set_known_shape(mut self, known_shape: bool) -> Self { + self.known_shape = known_shape; + self + } + + #[must_use] + pub const fn set_finalize_entities(mut self, finalize_entities: bool) -> Self { + self.finalize_entities = finalize_entities; + self + } + + #[must_use] + pub fn add_processor(mut self, processor: StructureProcessor) -> Self { + self.processors.push(processor); + self + } + + #[must_use] + pub const fn get_mirror(&self) -> Mirror { + self.mirror + } + + #[must_use] + pub const fn get_rotation(&self) -> Rotation { + self.rotation + } + + #[must_use] + pub const fn get_rotation_pivot(&self) -> Vector3 { + self.rotation_pivot + } + + #[must_use] + pub const fn get_bounding_box(&self) -> Option<&BlockBox> { + self.bounding_box.as_ref() + } + + #[must_use] + pub const fn is_ignore_entities(&self) -> bool { + self.ignore_entities + } + + #[must_use] + pub const fn should_apply_waterlogging(&self) -> bool { + self.apply_waterlogging + } + + #[must_use] + pub const fn get_known_shape(&self) -> bool { + self.known_shape + } + + #[must_use] + pub fn get_processors(&self) -> &[StructureProcessor] { + &self.processors + } + + #[must_use] + pub const fn should_finalize_entities(&self) -> bool { + self.finalize_entities + } + + #[must_use] + pub fn get_random_palette<'a>( + &self, + palettes: &'a [Palette], + pos: Vector3, + ) -> &'a Palette { + assert!( + !palettes.is_empty(), + "Cannot get palette from empty palette list" + ); + if let Some(idx) = self.palette_index + && idx < palettes.len() + { + return &palettes[idx]; + } + let index = ((pos.x.wrapping_add(pos.y).wrapping_add(pos.z)).unsigned_abs() as usize) + % palettes.len(); + &palettes[index] + } +} + +/// A loaded structure template from an NBT file matching vanilla `StructureTemplate`. +#[derive(Debug, Clone, Default)] pub struct StructureTemplate { - /// The dimensions of the template (width, height, depth). + pub palettes: Vec, + pub entity_info_list: Vec, pub size: Vector3, + pub author: String, - /// The palette of block states used in this template. + // Backward-compatible fields pub palette: Vec, - - /// The blocks in this template, referencing palette indices. pub blocks: Vec, - - /// Entities to spawn when placing this template (future use). pub entities: Vec, } /// A single entry in the template's block palette. -/// -/// Each palette entry defines a block type with optional properties. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PaletteEntry { /// The block name (e.g., "`minecraft:stone_bricks`"). pub name: String, @@ -95,154 +249,786 @@ impl PaletteEntry { }, ) } + + /// Rotates this block state by the given rotation. + #[must_use] + pub fn rotate(&self, rotation: Rotation) -> Self { + self.transform(rotation, Mirror::None) + } + + /// Mirrors this block state by the given mirror. + #[must_use] + pub fn mirror(&self, mirror: Mirror) -> Self { + self.transform(Rotation::None, mirror) + } + + /// Rotates and mirrors this block state. + #[must_use] + pub fn transform(&self, rotation: Rotation, mirror: Mirror) -> Self { + if rotation == Rotation::None && mirror == Mirror::None { + return self.clone(); + } + + let properties: Vec<(String, String)> = self + .properties + .iter() + .map(|(key, value)| { + let transformed_key = match key.as_str() { + "north" | "south" | "east" | "west" => rotation + .rotate_facing(mirror.mirror_facing(key)) + .to_string(), + _ => key.clone(), + }; + + let transformed_value = match key.as_str() { + "facing" => { + let mirrored = mirror.mirror_facing(value); + rotation.rotate_facing(mirrored).to_string() + } + "orientation" => { + let mut parts = value.split('_'); + if let (Some(front), Some(top)) = (parts.next(), parts.next()) { + let mirrored_front = mirror.mirror_facing(front); + let rotated_front = rotation.rotate_facing(mirrored_front); + let mirrored_top = mirror.mirror_facing(top); + let rotated_top = rotation.rotate_facing(mirrored_top); + format!("{rotated_front}_{rotated_top}") + } else { + value.clone() + } + } + "axis" => rotation.rotate_axis(value).to_string(), + "rotation" => value.parse::().map_or_else( + |_| value.clone(), + |rot_value| { + let mirrored = mirror.mirror_block_rotation(rot_value); + let rotated = rotation.rotate_block_rotation(mirrored); + rotated.to_string() + }, + ), + _ => value.clone(), + }; + + (transformed_key, transformed_value) + }) + .collect(); + + Self { + name: self.name.clone(), + properties, + } + } + + /// Converts this palette entry to an NBT compound tag. + #[must_use] + pub fn to_nbt_compound(&self) -> NbtCompound { + let mut compound = NbtCompound::new(); + compound.put_string("Name", self.name.clone()); + if !self.properties.is_empty() { + let mut props = NbtCompound::new(); + for (k, v) in &self.properties { + props.put_string(k, v.clone()); + } + compound.put_compound("Properties", props); + } + compound + } + + /// Deserializes a palette entry from an NBT compound tag. + pub fn from_nbt_compound(entry_compound: &NbtCompound) -> Result { + let name = entry_compound + .get_string("Name") + .ok_or(TemplateError::MissingField("palette.Name"))? + .to_string(); + + let properties: Vec<(String, String)> = entry_compound + .get_compound("Properties") + .map_or_else(Vec::new, |props_compound| { + props_compound + .child_tags + .iter() + .filter_map(|(key, value)| { + if let NbtTag::String(v) = value { + Some((key.to_string(), v.to_string())) + } else { + None + } + }) + .collect() + }); + + Ok(Self { name, properties }) + } } -/// A single block placement in the template. -#[derive(Debug, Clone)] -pub struct TemplateBlock { - /// Position relative to the template origin. +/// Block info record within a template. +#[derive(Debug, Clone, PartialEq)] +pub struct StructureBlockInfo { pub pos: Vector3, - - /// Index into the template's palette. - pub state: u32, - - /// Optional NBT data for block entities (chests, signs, etc.). + pub state: PaletteEntry, pub nbt: Option, } -/// An entity to spawn when placing the template. +impl StructureBlockInfo { + #[must_use] + pub const fn new(pos: Vector3, state: PaletteEntry, nbt: Option) -> Self { + Self { pos, state, nbt } + } +} + +/// Entity info record within a template. +#[derive(Debug, Clone, PartialEq)] +pub struct StructureEntityInfo { + pub pos: Vector3, + pub block_pos: Vector3, + pub nbt: NbtCompound, +} + +impl StructureEntityInfo { + #[must_use] + pub const fn new(pos: Vector3, block_pos: Vector3, nbt: NbtCompound) -> Self { + Self { + pos, + block_pos, + nbt, + } + } +} + +/// Jigsaw metadata extracted from a jigsaw block in a structure template. +#[derive(Debug, Clone, PartialEq)] +pub struct JigsawBlockInfo { + pub info: StructureBlockInfo, + pub joint_type: JigsawJointType, + pub name: String, + pub pool: String, + pub target: String, + pub placement_priority: i32, + pub selection_priority: i32, +} + +impl JigsawBlockInfo { + #[must_use] + pub fn of(info: StructureBlockInfo) -> Self { + let nbt = info.nbt.as_ref(); + let joint_type = StructureTemplate::get_joint_type(nbt, &info.state); + let name = nbt + .and_then(|n| n.get_string("name")) + .unwrap_or("minecraft:empty") + .to_string(); + let pool = nbt + .and_then(|n| n.get_string("pool")) + .unwrap_or("minecraft:empty") + .to_string(); + let target = nbt + .and_then(|n| n.get_string("target")) + .unwrap_or("minecraft:empty") + .to_string(); + let placement_priority = nbt + .and_then(|n| n.get_int("placement_priority")) + .unwrap_or(0); + let selection_priority = nbt + .and_then(|n| n.get_int("selection_priority")) + .unwrap_or(0); + + Self { + info, + joint_type, + name, + pool, + target, + placement_priority, + selection_priority, + } + } + + #[must_use] + pub fn with_info(&self, info: StructureBlockInfo) -> Self { + Self { + info, + joint_type: self.joint_type, + name: self.name.clone(), + pool: self.pool.clone(), + target: self.target.clone(), + placement_priority: self.placement_priority, + selection_priority: self.selection_priority, + } + } +} + +/// Represents a palette of block placements within a template. +#[derive(Debug, Clone)] +pub struct Palette { + blocks: Vec, + cached_jigsaws: Option>, +} + +impl Palette { + #[must_use] + pub const fn new(blocks: Vec) -> Self { + Self { + blocks, + cached_jigsaws: None, + } + } + + #[must_use] + pub fn blocks(&self) -> &[StructureBlockInfo] { + &self.blocks + } + + #[must_use] + pub fn blocks_by_name(&self, filter: &str) -> Vec { + self.blocks + .iter() + .filter(|b| b.state.name == filter) + .cloned() + .collect() + } + + pub fn jigsaws(&mut self) -> &[JigsawBlockInfo] { + self.cached_jigsaws.get_or_insert_with(|| { + self.blocks + .iter() + .filter(|b| b.state.name == "minecraft:jigsaw") + .map(|b| JigsawBlockInfo::of(b.clone())) + .collect() + }) + } +} + +/// Helper mapping table between palette entries and integer indices. +#[derive(Debug, Default, Clone)] +pub struct SimplePalette { + pub entries: Vec, +} + +impl SimplePalette { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn id_for(&mut self, state: PaletteEntry) -> u32 { + if let Some(pos) = self.entries.iter().position(|e| *e == state) { + pos as u32 + } else { + let id = self.entries.len() as u32; + self.entries.push(state); + id + } + } + + #[must_use] + pub fn state_for(&self, index: usize) -> Option<&PaletteEntry> { + self.entries.get(index) + } + + pub fn add_mapping(&mut self, state: PaletteEntry, id: usize) { + if id >= self.entries.len() { + self.entries + .resize(id + 1, PaletteEntry::new("minecraft:air".to_string())); + } + self.entries[id] = state; + } + + #[must_use] + pub const fn len(&self) -> usize { + self.entries.len() + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// A single block placement in the template (legacy format). +#[derive(Debug, Clone)] +pub struct TemplateBlock { + pub pos: Vector3, + pub state: u32, + pub nbt: Option, +} + +/// An entity to spawn when placing the template (legacy format). #[derive(Debug, Clone)] pub struct TemplateEntity { - /// Position relative to the template origin. pub pos: Vector3, - - /// Block position (snapped to grid). pub block_pos: Vector3, - - /// Entity NBT data. pub nbt: NbtCompound, } impl StructureTemplate { + pub const PALETTE_TAG: &'static str = "palette"; + pub const PALETTE_LIST_TAG: &'static str = "palettes"; + pub const ENTITIES_TAG: &'static str = "entities"; + pub const BLOCKS_TAG: &'static str = "blocks"; + pub const BLOCK_TAG_POS: &'static str = "pos"; + pub const BLOCK_TAG_STATE: &'static str = "state"; + pub const BLOCK_TAG_NBT: &'static str = "nbt"; + pub const ENTITY_TAG_POS: &'static str = "pos"; + pub const ENTITY_TAG_BLOCKPOS: &'static str = "blockPos"; + pub const ENTITY_TAG_NBT: &'static str = "nbt"; + pub const SIZE_TAG: &'static str = "size"; + + #[must_use] + pub const fn get_size(&self) -> Vector3 { + self.size + } + + pub fn set_author(&mut self, author: String) { + self.author = author; + } + + #[must_use] + pub fn get_author(&self) -> &str { + &self.author + } + + #[must_use] + pub fn palettes(&self) -> &[Palette] { + &self.palettes + } + + pub const fn palettes_mut(&mut self) -> &mut Vec { + &mut self.palettes + } + + #[must_use] + pub fn entity_info_list(&self) -> &[StructureEntityInfo] { + &self.entity_info_list + } + + #[must_use] + pub const fn get_size_with_rotation(&self, rotation: Rotation) -> Vector3 { + match rotation { + Rotation::CounterClockwise90 | Rotation::Clockwise90 => { + Vector3::new(self.size.z, self.size.y, self.size.x) + } + _ => self.size, + } + } + + /// Transforms a block position by mirror, rotation, and pivot. + #[must_use] + pub const fn transform_block_pos( + pos: Vector3, + mirror: Mirror, + rotation: Rotation, + pivot: Vector3, + ) -> Vector3 { + let mut x = pos.x; + let y = pos.y; + let mut z = pos.z; + let mut was_mirrored = true; + match mirror { + Mirror::LeftRight => z = -z, + Mirror::FrontBack => x = -x, + Mirror::None => was_mirrored = false, + } + + let pivot_x = pivot.x; + let pivot_z = pivot.z; + + match rotation { + Rotation::CounterClockwise90 => { + Vector3::new(pivot_x - pivot_z + z, y, pivot_x + pivot_z - x) + } + Rotation::Clockwise90 => Vector3::new(pivot_x + pivot_z - z, y, pivot_z - pivot_x + x), + Rotation::Rotate180 => Vector3::new(pivot_x + pivot_x - x, y, pivot_z + pivot_z - z), + Rotation::None => { + if was_mirrored { + Vector3::new(x, y, z) + } else { + pos + } + } + } + } + + /// Transforms a Vec3 double position by mirror, rotation, and pivot. + #[must_use] + pub fn transform_vec3( + pos: Vector3, + mirror: Mirror, + rotation: Rotation, + pivot: Vector3, + ) -> Vector3 { + let mut x = pos.x; + let y = pos.y; + let mut z = pos.z; + let mut was_mirrored = true; + match mirror { + Mirror::LeftRight => z = 1.0 - z, + Mirror::FrontBack => x = 1.0 - x, + Mirror::None => was_mirrored = false, + } + + let pivot_x = pivot.x as f64; + let pivot_z = pivot.z as f64; + + match rotation { + Rotation::CounterClockwise90 => { + Vector3::new(pivot_x - pivot_z + z, y, pivot_x + pivot_z + 1.0 - x) + } + Rotation::Clockwise90 => { + Vector3::new(pivot_x + pivot_z + 1.0 - z, y, pivot_z - pivot_x + x) + } + Rotation::Rotate180 => { + Vector3::new(pivot_x + pivot_x + 1.0 - x, y, pivot_z + pivot_z + 1.0 - z) + } + Rotation::None => { + if was_mirrored { + Vector3::new(x, y, z) + } else { + pos + } + } + } + } + + #[must_use] + pub fn get_zero_position_with_transform( + &self, + zero_pos: Vector3, + mirror: Mirror, + rotation: Rotation, + ) -> Vector3 { + Self::get_zero_position_with_transform_size( + zero_pos, + mirror, + rotation, + self.size.x, + self.size.z, + ) + } + + #[must_use] + pub fn get_zero_position_with_transform_size( + zero_pos: Vector3, + mirror: Mirror, + rotation: Rotation, + mut size_x: i32, + mut size_z: i32, + ) -> Vector3 { + size_x -= 1; + size_z -= 1; + let mirror_delta_x = if mirror == Mirror::FrontBack { + size_x + } else { + 0 + }; + let mirror_delta_z = if mirror == Mirror::LeftRight { + size_z + } else { + 0 + }; + + match rotation { + Rotation::CounterClockwise90 => { + zero_pos + Vector3::new(mirror_delta_z, 0, size_x - mirror_delta_x) + } + Rotation::Clockwise90 => { + zero_pos + Vector3::new(size_z - mirror_delta_z, 0, mirror_delta_x) + } + Rotation::Rotate180 => { + zero_pos + Vector3::new(size_x - mirror_delta_x, 0, size_z - mirror_delta_z) + } + Rotation::None => zero_pos + Vector3::new(mirror_delta_x, 0, mirror_delta_z), + } + } + + #[must_use] + pub const fn calculate_relative_position( + settings: &StructurePlaceSettings, + pos: Vector3, + ) -> Vector3 { + Self::transform_block_pos( + pos, + settings.get_mirror(), + settings.get_rotation(), + settings.get_rotation_pivot(), + ) + } + + #[must_use] + pub fn calculate_connected_position( + settings1: &StructurePlaceSettings, + connection1: Vector3, + settings2: &StructurePlaceSettings, + connection2: Vector3, + ) -> Vector3 { + let marker_pos1 = Self::calculate_relative_position(settings1, connection1); + let marker_pos2 = Self::calculate_relative_position(settings2, connection2); + marker_pos1 - marker_pos2 + } + + #[must_use] + pub fn get_bounding_box( + &self, + settings: &StructurePlaceSettings, + position: Vector3, + ) -> BlockBox { + self.get_bounding_box_with_pivot( + position, + settings.get_rotation(), + settings.get_rotation_pivot(), + settings.get_mirror(), + ) + } + + #[must_use] + pub fn get_bounding_box_with_pivot( + &self, + position: Vector3, + rotation: Rotation, + pivot: Vector3, + mirror: Mirror, + ) -> BlockBox { + Self::get_bounding_box_static(position, rotation, pivot, mirror, self.size) + } + + #[must_use] + pub fn get_bounding_box_static( + position: Vector3, + rotation: Rotation, + pivot: Vector3, + mirror: Mirror, + size: Vector3, + ) -> BlockBox { + let delta = Vector3::new(size.x - 1, size.y - 1, size.z - 1); + let corner1 = Self::transform_block_pos(Vector3::new(0, 0, 0), mirror, rotation, pivot); + let corner2 = Self::transform_block_pos(delta, mirror, rotation, pivot); + let min_x = corner1.x.min(corner2.x); + let min_y = corner1.y.min(corner2.y); + let min_z = corner1.z.min(corner2.z); + let max_x = corner1.x.max(corner2.x); + let max_y = corner1.y.max(corner2.y); + let max_z = corner1.z.max(corner2.z); + let mut bb = BlockBox::new(min_x, min_y, min_z, max_x, max_y, max_z); + bb.move_pos(position.x, position.y, position.z); + bb + } + + pub fn get_jigsaws( + &mut self, + position: Vector3, + rotation: Rotation, + ) -> Vec { + if self.palettes.is_empty() { + return Vec::new(); + } + + let settings = StructurePlaceSettings::new().set_rotation(rotation); + let jigsaws = { + let palette = settings.get_random_palette(&self.palettes, position); + let mut pal_clone = palette.clone(); + pal_clone.jigsaws().to_vec() + }; + + let mut result = Vec::with_capacity(jigsaws.len()); + for jigsaw in jigsaws { + let block_info = &jigsaw.info; + let rel_pos = Self::calculate_relative_position(&settings, block_info.pos) + position; + let rotated_info = StructureBlockInfo::new( + rel_pos, + block_info.state.rotate(settings.get_rotation()), + block_info.nbt.clone(), + ); + result.push(jigsaw.with_info(rotated_info)); + } + + result + } + + #[must_use] + pub fn filter_blocks( + &self, + position: Vector3, + settings: &StructurePlaceSettings, + block: &str, + ) -> Vec { + self.filter_blocks_with_absolute(position, settings, block, true) + } + + #[must_use] + pub fn filter_blocks_with_absolute( + &self, + position: Vector3, + settings: &StructurePlaceSettings, + block: &str, + absolute: bool, + ) -> Vec { + if self.palettes.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let bounding_box = settings.get_bounding_box(); + let palette = settings.get_random_palette(&self.palettes, position); + + for block_info in palette.blocks() { + if block_info.state.name == block { + let block_pos = if absolute { + Self::calculate_relative_position(settings, block_info.pos) + position + } else { + block_info.pos + }; + if bounding_box.is_none_or(|bb| bb.contains_pos(&block_pos)) { + result.push(StructureBlockInfo::new( + block_pos, + block_info.state.rotate(settings.get_rotation()), + block_info.nbt.clone(), + )); + } + } + } + + result + } + + #[must_use] + pub fn get_joint_type(nbt: Option<&NbtCompound>, state: &PaletteEntry) -> JigsawJointType { + nbt.and_then(|n| n.get_string("joint")).map_or_else( + || Self::get_default_joint_type(state), + JigsawJointType::from_str, + ) + } + + #[must_use] + pub fn get_default_joint_type(state: &PaletteEntry) -> JigsawJointType { + if let Some((_, orientation)) = state.properties.iter().find(|(k, _)| k == "orientation") { + let front = orientation + .split('_') + .next() + .unwrap_or(orientation.as_str()); + match front { + "north" | "south" | "east" | "west" => JigsawJointType::Aligned, + _ => JigsawJointType::Rollable, + } + } else { + JigsawJointType::Rollable + } + } + /// Loads a structure template from gzipped NBT bytes. - /// - /// # Errors - /// - /// Returns an error if the NBT data is invalid or missing required fields. pub fn from_nbt_bytes(bytes: &[u8]) -> Result { let compound = read_gzip_compound_tag(Cursor::new(bytes))?; Self::from_nbt_compound(&compound) } /// Loads a structure template from a parsed NBT compound. - /// - /// # Errors - /// - /// Returns an error if required fields are missing or have invalid types. pub fn from_nbt_compound(compound: &NbtCompound) -> Result { - // Parse size - let size = Self::parse_size(compound)?; - - // Parse palette - let palette = Self::parse_palette(compound)?; - - // Parse blocks - let blocks = Self::parse_blocks(compound, palette.len())?; - - // Parse entities (optional) - let entities = Self::parse_entities(compound).unwrap_or_default(); - - Ok(Self { - size, - palette, - blocks, - entities, - }) + let mut template = Self::default(); + template.load(compound)?; + Ok(template) } - fn parse_size(compound: &NbtCompound) -> Result, TemplateError> { - let size_list = compound - .get_list("size") - .ok_or(TemplateError::MissingField("size"))?; + /// Loads the structure template from an NBT compound matching vanilla 1:1. + pub fn load(&mut self, compound: &NbtCompound) -> Result<(), TemplateError> { + self.palettes.clear(); + self.entity_info_list.clear(); - if size_list.len() != 3 { - return Err(TemplateError::InvalidFieldType("size")); + // 1. size + self.size = Self::parse_size(compound)?; + + // 2. blocks + let blocks_list = compound + .get_list(Self::BLOCKS_TAG) + .ok_or(TemplateError::MissingField(Self::BLOCKS_TAG))?; + + // 3. palettes + if let Some(palette_list_list) = compound.get_list(Self::PALETTE_LIST_TAG) { + for tag in palette_list_list { + let NbtTag::List(palette_list) = tag else { + return Err(TemplateError::InvalidFieldType(Self::PALETTE_LIST_TAG)); + }; + self.load_palette(palette_list, blocks_list)?; + } + } else if let Some(palette_list) = compound.get_list(Self::PALETTE_TAG) { + self.load_palette(palette_list, blocks_list)?; + } else { + return Err(TemplateError::MissingField(Self::PALETTE_TAG)); } - let x = Self::extract_int(&size_list[0]).ok_or(TemplateError::InvalidFieldType("size"))?; - let y = Self::extract_int(&size_list[1]).ok_or(TemplateError::InvalidFieldType("size"))?; - let z = Self::extract_int(&size_list[2]).ok_or(TemplateError::InvalidFieldType("size"))?; + // 4. entities + if let Some(entities_list) = compound.get_list(Self::ENTITIES_TAG) { + for tag in entities_list { + let NbtTag::Compound(entity_compound) = tag else { + return Err(TemplateError::InvalidFieldType("entities entry")); + }; - Ok(Vector3::new(x, y, z)) + let pos_list = entity_compound + .get_list(Self::ENTITY_TAG_POS) + .ok_or(TemplateError::MissingField("entities.pos"))?; + if pos_list.len() != 3 { + return Err(TemplateError::InvalidFieldType("entities.pos")); + } + let x = Self::extract_double(&pos_list[0]) + .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; + let y = Self::extract_double(&pos_list[1]) + .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; + let z = Self::extract_double(&pos_list[2]) + .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; + + let block_pos_list = entity_compound + .get_list(Self::ENTITY_TAG_BLOCKPOS) + .ok_or(TemplateError::MissingField("entities.blockPos"))?; + if block_pos_list.len() != 3 { + return Err(TemplateError::InvalidFieldType("entities.blockPos")); + } + let bx = Self::extract_int(&block_pos_list[0]) + .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; + let by = Self::extract_int(&block_pos_list[1]) + .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; + let bz = Self::extract_int(&block_pos_list[2]) + .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; + + let nbt = entity_compound + .get_compound(Self::ENTITY_TAG_NBT) + .cloned() + .unwrap_or_default(); + + self.entity_info_list.push(StructureEntityInfo::new( + Vector3::new(x, y, z), + Vector3::new(bx, by, bz), + nbt, + )); + } + } + + self.sync_legacy_fields(); + + Ok(()) } - fn parse_palette(compound: &NbtCompound) -> Result, TemplateError> { - let palette_list = if let Some(list) = compound.get_list("palette") { - list - } else if let Some(palettes) = compound.get_list("palettes") { - if let Some(NbtTag::List(list)) = palettes.first() { - list - } else { - return Err(TemplateError::MissingField("palette")); - } - } else { - return Err(TemplateError::MissingField("palette")); - }; - - let mut palette = Vec::with_capacity(palette_list.len()); - - for tag in palette_list { + fn load_palette( + &mut self, + palette_list: &[NbtTag], + block_list: &[NbtTag], + ) -> Result<(), TemplateError> { + let mut palette = SimplePalette::new(); + for (i, tag) in palette_list.iter().enumerate() { let NbtTag::Compound(entry_compound) = tag else { return Err(TemplateError::InvalidFieldType("palette entry")); }; - - let name = entry_compound - .get_string("Name") - .ok_or(TemplateError::MissingField("palette.Name"))? - .to_string(); - - let properties: Vec<(String, String)> = entry_compound - .get_compound("Properties") - .map_or_else(Vec::new, |props_compound| { - props_compound - .child_tags - .iter() - .filter_map(|(key, value)| { - if let NbtTag::String(v) = value { - Some((key.to_string(), v.to_string())) - } else { - None - } - }) - .collect() - }); - - palette.push(PaletteEntry { name, properties }); + let entry = PaletteEntry::from_nbt_compound(entry_compound)?; + palette.add_mapping(entry, i); } - Ok(palette) - } + let mut full_block_list = Vec::new(); + let mut block_entities_list = Vec::new(); + let mut other_blocks_list = Vec::new(); - fn parse_blocks( - compound: &NbtCompound, - palette_size: usize, - ) -> Result, TemplateError> { - let blocks_list = compound - .get_list("blocks") - .ok_or(TemplateError::MissingField("blocks"))?; - - let mut blocks = Vec::with_capacity(blocks_list.len()); - - for tag in blocks_list { + for tag in block_list { let NbtTag::Compound(block_compound) = tag else { return Err(TemplateError::InvalidFieldType("blocks entry")); }; - // Parse position let pos_list = block_compound - .get_list("pos") + .get_list(Self::BLOCK_TAG_POS) .ok_or(TemplateError::MissingField("blocks.pos"))?; - if pos_list.len() != 3 { return Err(TemplateError::InvalidFieldType("blocks.pos")); } @@ -254,89 +1040,245 @@ impl StructureTemplate { let z = Self::extract_int(&pos_list[2]).ok_or(TemplateError::InvalidFieldType("pos"))?; - // Parse state (palette index) - let state = block_compound - .get_int("state") - .ok_or(TemplateError::MissingField("blocks.state"))? as u32; + let state_idx = block_compound + .get_int(Self::BLOCK_TAG_STATE) + .ok_or(TemplateError::MissingField("blocks.state"))? + as usize; - if state as usize >= palette_size { - return Err(TemplateError::InvalidPaletteIndex(state)); - } - - // Parse optional NBT - let nbt = block_compound.get_compound("nbt").cloned(); - - blocks.push(TemplateBlock { - pos: Vector3::new(x, y, z), - state, - nbt, - }); - } - - Ok(blocks) - } - - fn parse_entities(compound: &NbtCompound) -> Result, TemplateError> { - let Some(entities_list) = compound.get_list("entities") else { - return Ok(Vec::new()); - }; - - let mut entities = Vec::with_capacity(entities_list.len()); - - for tag in entities_list { - let NbtTag::Compound(entity_compound) = tag else { - return Err(TemplateError::InvalidFieldType("entities entry")); - }; - - // Parse position (double coordinates) - let pos_list = entity_compound - .get_list("pos") - .ok_or(TemplateError::MissingField("entities.pos"))?; - - if pos_list.len() != 3 { - return Err(TemplateError::InvalidFieldType("entities.pos")); - } - - let x = Self::extract_double(&pos_list[0]) - .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; - let y = Self::extract_double(&pos_list[1]) - .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; - let z = Self::extract_double(&pos_list[2]) - .ok_or(TemplateError::InvalidFieldType("entities.pos"))?; - - // Parse block position - let block_pos_list = entity_compound - .get_list("blockPos") - .ok_or(TemplateError::MissingField("entities.blockPos"))?; - - if block_pos_list.len() != 3 { - return Err(TemplateError::InvalidFieldType("entities.blockPos")); - } - - let bx = Self::extract_int(&block_pos_list[0]) - .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; - let by = Self::extract_int(&block_pos_list[1]) - .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; - let bz = Self::extract_int(&block_pos_list[2]) - .ok_or(TemplateError::InvalidFieldType("entities.blockPos"))?; - - // Parse NBT - let nbt = entity_compound - .get_compound("nbt") + let state = palette + .state_for(state_idx) .cloned() - .ok_or(TemplateError::MissingField("entities.nbt"))?; + .unwrap_or_else(|| PaletteEntry::new("minecraft:air".to_string())); - entities.push(TemplateEntity { - pos: Vector3::new(x, y, z), - block_pos: Vector3::new(bx, by, bz), - nbt, - }); + let nbt = block_compound.get_compound(Self::BLOCK_TAG_NBT).cloned(); + + let info = StructureBlockInfo::new(Vector3::new(x, y, z), state, nbt); + Self::add_to_lists( + info, + &mut full_block_list, + &mut block_entities_list, + &mut other_blocks_list, + ); } - Ok(entities) + let block_info_list = + Self::build_info_list(full_block_list, block_entities_list, other_blocks_list); + self.palettes.push(Palette::new(block_info_list)); + + Ok(()) + } + + fn add_to_lists( + info: StructureBlockInfo, + full_block_list: &mut Vec, + block_entities_list: &mut Vec, + other_blocks_list: &mut Vec, + ) { + if info.nbt.is_some() { + block_entities_list.push(info); + } else if Self::is_full_cube_block(&info.state.name) { + full_block_list.push(info); + } else { + other_blocks_list.push(info); + } + } + + fn is_full_cube_block(name: &str) -> bool { + let stripped = name.strip_prefix("minecraft:").unwrap_or(name); + !(stripped.contains("air") + || stripped.contains("stair") + || stripped.contains("slab") + || stripped.contains("fence") + || stripped.contains("wall") + || stripped.contains("door") + || stripped.contains("torch") + || stripped.contains("pane") + || stripped.contains("glass_pane") + || stripped.contains("lantern") + || stripped.contains("carpet") + || stripped.contains("chain") + || stripped.contains("iron_bars") + || stripped.contains("candle") + || stripped.contains("bell") + || stripped.contains("scaffolding")) + } + + fn build_info_list( + mut full_block_list: Vec, + mut block_entities_list: Vec, + mut other_blocks_list: Vec, + ) -> Vec { + let comparator = |a: &StructureBlockInfo, b: &StructureBlockInfo| { + a.pos + .y + .cmp(&b.pos.y) + .then_with(|| a.pos.x.cmp(&b.pos.x)) + .then_with(|| a.pos.z.cmp(&b.pos.z)) + }; + full_block_list.sort_by(comparator); + other_blocks_list.sort_by(comparator); + block_entities_list.sort_by(comparator); + + let mut result = Vec::with_capacity( + full_block_list.len() + other_blocks_list.len() + block_entities_list.len(), + ); + result.extend(full_block_list); + result.extend(other_blocks_list); + result.extend(block_entities_list); + result + } + + fn sync_legacy_fields(&mut self) { + if let Some(main_palette) = self.palettes.first() { + let mut simple_palette = SimplePalette::new(); + let mut blocks = Vec::with_capacity(main_palette.blocks().len()); + for b in main_palette.blocks() { + let state_id = simple_palette.id_for(b.state.clone()); + blocks.push(TemplateBlock { + pos: b.pos, + state: state_id, + nbt: b.nbt.clone(), + }); + } + self.palette = simple_palette.entries; + self.blocks = blocks; + } else { + self.palette.clear(); + self.blocks.clear(); + } + + self.entities = self + .entity_info_list + .iter() + .map(|e| TemplateEntity { + pos: e.pos, + block_pos: e.block_pos, + nbt: e.nbt.clone(), + }) + .collect(); + } + + /// Saves this structure template to an NBT compound matching vanilla 1:1. + #[must_use] + pub fn save(&self) -> NbtCompound { + let mut tag = NbtCompound::new(); + + if self.palettes.is_empty() { + tag.put_list(Self::BLOCKS_TAG, Vec::new()); + tag.put_list(Self::PALETTE_TAG, Vec::new()); + } else { + let mut palettes = Vec::with_capacity(self.palettes.len()); + let main_palette = SimplePalette::new(); + palettes.push(main_palette); + for _ in 1..self.palettes.len() { + palettes.push(SimplePalette::new()); + } + + let mut block_list = Vec::new(); + let main_palette_blocks = self.palettes[0].blocks(); + + for (i, block_info) in main_palette_blocks.iter().enumerate() { + let mut block_tag = NbtCompound::new(); + let pos_tags = vec![ + NbtTag::Int(block_info.pos.x), + NbtTag::Int(block_info.pos.y), + NbtTag::Int(block_info.pos.z), + ]; + block_tag.put_list(Self::BLOCK_TAG_POS, pos_tags); + + let id = palettes[0].id_for(block_info.state.clone()); + block_tag.put_int(Self::BLOCK_TAG_STATE, id as i32); + + if let Some(nbt) = &block_info.nbt { + block_tag.put_compound(Self::BLOCK_TAG_NBT, nbt.clone()); + } + + block_list.push(NbtTag::Compound(block_tag)); + + for p in 1..self.palettes.len() { + let state = self.palettes[p].blocks()[i].state.clone(); + palettes[p].add_mapping(state, id as usize); + } + } + + tag.put_list(Self::BLOCKS_TAG, block_list); + + if palettes.len() == 1 { + let palette_tags: Vec = palettes[0] + .entries + .iter() + .map(|state| NbtTag::Compound(state.to_nbt_compound())) + .collect(); + tag.put_list(Self::PALETTE_TAG, palette_tags); + } else { + let palette_list_list: Vec = palettes + .into_iter() + .map(|pal| { + let inner_tags: Vec = pal + .entries + .into_iter() + .map(|state| NbtTag::Compound(state.to_nbt_compound())) + .collect(); + NbtTag::List(inner_tags) + }) + .collect(); + tag.put_list(Self::PALETTE_LIST_TAG, palette_list_list); + } + } + + let mut entity_list = Vec::new(); + for entity_info in &self.entity_info_list { + let mut entity_tag = NbtCompound::new(); + let pos_tags = vec![ + NbtTag::Double(entity_info.pos.x), + NbtTag::Double(entity_info.pos.y), + NbtTag::Double(entity_info.pos.z), + ]; + entity_tag.put_list(Self::ENTITY_TAG_POS, pos_tags); + + let block_pos_tags = vec![ + NbtTag::Int(entity_info.block_pos.x), + NbtTag::Int(entity_info.block_pos.y), + NbtTag::Int(entity_info.block_pos.z), + ]; + entity_tag.put_list(Self::ENTITY_TAG_BLOCKPOS, block_pos_tags); + + entity_tag.put_compound(Self::ENTITY_TAG_NBT, entity_info.nbt.clone()); + entity_list.push(NbtTag::Compound(entity_tag)); + } + tag.put_list(Self::ENTITIES_TAG, entity_list); + + let size_tags = vec![ + NbtTag::Int(self.size.x), + NbtTag::Int(self.size.y), + NbtTag::Int(self.size.z), + ]; + tag.put_list(Self::SIZE_TAG, size_tags); + + tag.put_int("DataVersion", 4189); + + tag + } + + fn parse_size(compound: &NbtCompound) -> Result, TemplateError> { + let size_list = compound + .get_list(Self::SIZE_TAG) + .ok_or(TemplateError::MissingField(Self::SIZE_TAG))?; + + if size_list.len() != 3 { + return Err(TemplateError::InvalidFieldType(Self::SIZE_TAG)); + } + + let x = Self::extract_int(&size_list[0]) + .ok_or(TemplateError::InvalidFieldType(Self::SIZE_TAG))?; + let y = Self::extract_int(&size_list[1]) + .ok_or(TemplateError::InvalidFieldType(Self::SIZE_TAG))?; + let z = Self::extract_int(&size_list[2]) + .ok_or(TemplateError::InvalidFieldType(Self::SIZE_TAG))?; + + Ok(Vector3::new(x, y, z)) } - /// Helper to extract an i32 from various NBT numeric types. fn extract_int(tag: &NbtTag) -> Option { match tag { NbtTag::Byte(v) => Some(i32::from(*v)), @@ -347,7 +1289,6 @@ impl StructureTemplate { } } - /// Helper to extract an f64 from various NBT numeric types. fn extract_double(tag: &NbtTag) -> Option { match tag { NbtTag::Float(v) => Some(f64::from(*v)), @@ -358,19 +1299,16 @@ impl StructureTemplate { } } - /// Returns the total number of blocks in this template. #[must_use] pub const fn block_count(&self) -> usize { self.blocks.len() } - /// Returns whether this template has any block entity data. #[must_use] pub fn has_block_entities(&self) -> bool { self.blocks.iter().any(|b| b.nbt.is_some()) } - /// Returns whether this template has any entities. #[must_use] pub const fn has_entities(&self) -> bool { !self.entities.is_empty() diff --git a/crates/pumpkin/src/command/commands/datapack.rs b/crates/pumpkin/src/command/commands/datapack.rs new file mode 100644 index 000000000..009bbad2f --- /dev/null +++ b/crates/pumpkin/src/command/commands/datapack.rs @@ -0,0 +1,681 @@ +use std::fs; + +use pumpkin_data::translation; +use pumpkin_util::PermissionLvl; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::TextComponent; +use pumpkin_util::text::color::NamedColor; +use tracing::error; + +use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; +use crate::command::argument_types::core::string::StringArgumentType; +use crate::command::context::command_context::CommandContext; +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::server::Server; + +const DESCRIPTION: &str = "Controls loaded data packs."; +const PERMISSION: &str = "minecraft:command.datapack"; + +static ERROR_UNKNOWN_DATAPACK: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_UNKNOWN, + translation::java::COMMANDS_DATAPACK_UNKNOWN, +); + +static ERROR_ENABLE_FAILED: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_ENABLE_FAILED, + translation::java::COMMANDS_DATAPACK_ENABLE_FAILED, +); + +static ERROR_DISABLE_FAILED: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_DISABLE_FAILED, + translation::java::COMMANDS_DATAPACK_DISABLE_FAILED, +); + +static ERROR_CREATE_ALREADY_EXISTS: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_CREATE_ALREADY_EXISTS, + translation::java::COMMANDS_DATAPACK_CREATE_ALREADY_EXISTS, +); + +static ERROR_CREATE_INVALID_NAME: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_CREATE_INVALID_NAME, + translation::java::COMMANDS_DATAPACK_CREATE_INVALID_NAME, +); + +static ERROR_CREATE_IO_FAILURE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_DATAPACK_CREATE_IO_FAILURE, + translation::java::COMMANDS_DATAPACK_CREATE_IO_FAILURE, +); + +fn get_all_known_packs(server: &Server) -> Vec { + let mut packs = Vec::new(); + packs.push("vanilla".to_string()); + + // Bundled feature packs + for bundled in [ + "trade_rebalance", + "minecart_improvements", + "redstone_experiments", + ] { + if !packs.iter().any(|p| p == bundled) { + packs.push(bundled.to_string()); + } + } + + // World datapacks directory + let datapacks_dir = server.basic_config.get_world_path().join("datapacks"); + if let Ok(entries) = fs::read_dir(datapacks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with('.') { + continue; + } + if path.is_dir() + || path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) + { + let pack_name = format!("file/{file_name}"); + if !packs.iter().any(|p| p == &pack_name) { + packs.push(pack_name); + } + } + } + } + + let level_info = server.level_info.load(); + for pack in &level_info.data_packs.enabled { + if !packs.iter().any(|p| p == pack) { + packs.push(pack.clone()); + } + } + for pack in &level_info.data_packs.disabled { + if !packs.iter().any(|p| p == pack) { + packs.push(pack.clone()); + } + } + + packs +} + +fn get_enabled_packs(server: &Server) -> Vec { + server.level_info.load().data_packs.enabled.clone() +} + +fn get_available_packs(server: &Server) -> Vec { + let enabled = get_enabled_packs(server); + let all = get_all_known_packs(server); + all.into_iter().filter(|p| !enabled.contains(p)).collect() +} + +fn find_pack_name(server: &Server, input: &str) -> Option { + let known = get_all_known_packs(server); + if let Some(p) = known.iter().find(|p| *p == input) { + return Some(p.clone()); + } + let file_input = format!("file/{input}"); + if let Some(p) = known.iter().find(|p| **p == file_input) { + return Some(p.clone()); + } + if let Some(p) = known + .iter() + .find(|p| p.strip_prefix("file/") == Some(input)) + { + return Some(p.clone()); + } + None +} + +fn format_pack(name: &str) -> TextComponent { + TextComponent::text(format!("[{name}]")).color_named(NamedColor::Green) +} + +struct AvailablePackSuggestionProvider; + +impl SuggestionProvider for AvailablePackSuggestionProvider { + fn suggest<'a>( + &'a self, + context: &'a CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult<'a> { + Box::pin(async move { + let server = context.server(); + for pack in get_available_packs(server) { + if pack.contains(' ') || pack.contains('/') { + builder = builder.suggest(format!("\"{pack}\"")); + } else { + builder = builder.suggest(pack.clone()); + } + if let Some(short) = pack.strip_prefix("file/") { + if short.contains(' ') { + builder = builder.suggest(format!("\"{short}\"")); + } else { + builder = builder.suggest(short.to_string()); + } + } + } + builder.build() + }) + } +} + +struct EnabledPackSuggestionProvider; + +impl SuggestionProvider for EnabledPackSuggestionProvider { + fn suggest<'a>( + &'a self, + context: &'a CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult<'a> { + Box::pin(async move { + let server = context.server(); + for pack in get_enabled_packs(server) { + if pack.contains(' ') || pack.contains('/') { + builder = builder.suggest(format!("\"{pack}\"")); + } else { + builder = builder.suggest(pack.clone()); + } + if let Some(short) = pack.strip_prefix("file/") { + if short.contains(' ') { + builder = builder.suggest(format!("\"{short}\"")); + } else { + builder = builder.suggest(short.to_string()); + } + } + } + builder.build() + }) + } +} + +#[derive(Clone, Copy)] +enum ListFilter { + All, + Available, + Enabled, +} + +struct DatapackListExecutor(ListFilter); + +impl CommandExecutor for DatapackListExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let server = context.server(); + let enabled = get_enabled_packs(server); + let available = get_available_packs(server); + + match self.0 { + ListFilter::All => { + send_enabled_list(context, &enabled).await; + send_available_list(context, &available).await; + Ok(enabled.len() as i32) + } + ListFilter::Enabled => { + send_enabled_list(context, &enabled).await; + Ok(enabled.len() as i32) + } + ListFilter::Available => { + send_available_list(context, &available).await; + Ok(available.len() as i32) + } + } + }) + } +} + +async fn send_enabled_list(context: &CommandContext<'_>, enabled: &[String]) { + if enabled.is_empty() { + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_LIST_ENABLED_NONE, + translation::java::COMMANDS_DATAPACK_LIST_ENABLED_NONE, + [], + ), + false, + ) + .await; + } else { + let packs_component = + TextComponent::join_with_comma(enabled.iter().map(|p| format_pack(p)).collect()); + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_LIST_ENABLED_SUCCESS, + translation::java::COMMANDS_DATAPACK_LIST_ENABLED_SUCCESS, + [ + TextComponent::text(enabled.len().to_string()), + packs_component, + ], + ), + false, + ) + .await; + } +} + +async fn send_available_list(context: &CommandContext<'_>, available: &[String]) { + if available.is_empty() { + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_NONE, + translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_NONE, + [], + ), + false, + ) + .await; + } else { + let packs_component = + TextComponent::join_with_comma(available.iter().map(|p| format_pack(p)).collect()); + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_SUCCESS, + translation::java::COMMANDS_DATAPACK_LIST_AVAILABLE_SUCCESS, + [ + TextComponent::text(available.len().to_string()), + packs_component, + ], + ), + false, + ) + .await; + } +} + +#[derive(Clone, Copy)] +enum EnablePosition { + First, + Last, +} + +struct DatapackEnableExecutor(EnablePosition); + +impl CommandExecutor for DatapackEnableExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name_str = StringArgumentType::get(context, "name")?; + let server = context.server(); + + let Some(resolved_name) = find_pack_name(server, name_str) else { + return Err(ERROR_UNKNOWN_DATAPACK + .create_without_context(TextComponent::text(name_str.to_string()))); + }; + + let enabled = get_enabled_packs(server); + if enabled.contains(&resolved_name) { + return Err( + ERROR_ENABLE_FAILED.create_without_context(TextComponent::text(resolved_name)) + ); + } + + let target = resolved_name.clone(); + let pos = self.0; + server.level_info.rcu(|level_info| { + let mut new_info = (**level_info).clone(); + new_info.data_packs.disabled.retain(|p| p != &target); + new_info.data_packs.enabled.retain(|p| p != &target); + match pos { + EnablePosition::First => { + new_info.data_packs.enabled.insert(0, target.clone()); + } + EnablePosition::Last => { + new_info.data_packs.enabled.push(target.clone()); + } + } + new_info + }); + + if let Err(err) = server.save_world_info() { + error!("Failed to save world info: {err}"); + } + + server.reload_datapacks(server).await; + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, + translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, + [format_pack(&resolved_name)], + ), + true, + ) + .await; + + Ok(server.level_info.load().data_packs.enabled.len() as i32) + }) + } +} + +#[derive(Clone, Copy)] +enum BeforeOrAfter { + Before, + After, +} + +struct DatapackEnableExistingExecutor(BeforeOrAfter); + +impl CommandExecutor for DatapackEnableExistingExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name_str = StringArgumentType::get(context, "name")?; + let existing_str = StringArgumentType::get(context, "existing")?; + let server = context.server(); + + let Some(target_pack) = find_pack_name(server, name_str) else { + return Err(ERROR_UNKNOWN_DATAPACK + .create_without_context(TextComponent::text(name_str.to_string()))); + }; + + let Some(existing_pack) = find_pack_name(server, existing_str) else { + return Err(ERROR_UNKNOWN_DATAPACK + .create_without_context(TextComponent::text(existing_str.to_string()))); + }; + + let enabled = get_enabled_packs(server); + if enabled.contains(&target_pack) { + return Err( + ERROR_ENABLE_FAILED.create_without_context(TextComponent::text(target_pack)) + ); + } + + if !enabled.contains(&existing_pack) { + return Err( + ERROR_DISABLE_FAILED.create_without_context(TextComponent::text(existing_pack)) + ); + } + + let target = target_pack.clone(); + let existing = existing_pack.clone(); + let before_or_after = self.0; + server.level_info.rcu(|level_info| { + let mut new_info = (**level_info).clone(); + new_info.data_packs.disabled.retain(|p| p != &target); + new_info.data_packs.enabled.retain(|p| p != &target); + + if let Some(idx) = new_info + .data_packs + .enabled + .iter() + .position(|p| p == &existing) + { + let insert_pos = match before_or_after { + BeforeOrAfter::Before => idx, + BeforeOrAfter::After => idx + 1, + }; + new_info + .data_packs + .enabled + .insert(insert_pos, target.clone()); + } else { + new_info.data_packs.enabled.push(target.clone()); + } + new_info + }); + + if let Err(err) = server.save_world_info() { + error!("Failed to save world info: {err}"); + } + + server.reload_datapacks(server).await; + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, + translation::java::COMMANDS_DATAPACK_MODIFY_ENABLE, + [format_pack(&target_pack)], + ), + true, + ) + .await; + + Ok(server.level_info.load().data_packs.enabled.len() as i32) + }) + } +} + +struct DatapackDisableExecutor; + +impl CommandExecutor for DatapackDisableExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name_str = StringArgumentType::get(context, "name")?; + let server = context.server(); + + let Some(target_pack) = find_pack_name(server, name_str) else { + return Err(ERROR_DISABLE_FAILED + .create_without_context(TextComponent::text(name_str.to_string()))); + }; + + let enabled = get_enabled_packs(server); + if !enabled.contains(&target_pack) { + return Err(ERROR_DISABLE_FAILED + .create_without_context(TextComponent::text(name_str.to_string()))); + } + + if target_pack == "vanilla" { + return Err(ERROR_DISABLE_FAILED + .create_without_context(TextComponent::text("vanilla".to_string()))); + } + + let target = target_pack.clone(); + server.level_info.rcu(|level_info| { + let mut new_info = (**level_info).clone(); + new_info.data_packs.enabled.retain(|p| p != &target); + if !new_info.data_packs.disabled.contains(&target) { + new_info.data_packs.disabled.push(target.clone()); + } + new_info + }); + + if let Err(err) = server.save_world_info() { + error!("Failed to save world info: {err}"); + } + + server.reload_datapacks(server).await; + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_MODIFY_DISABLE, + translation::java::COMMANDS_DATAPACK_MODIFY_DISABLE, + [format_pack(&target_pack)], + ), + true, + ) + .await; + + Ok(server.level_info.load().data_packs.enabled.len() as i32) + }) + } +} + +fn is_valid_pack_name(name: &str) -> bool { + !name.is_empty() + && !name.contains('/') + && !name.contains('\\') + && !name.contains("..") + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') +} + +struct DatapackCreateExecutor; + +impl CommandExecutor for DatapackCreateExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name_str = StringArgumentType::get(context, "name")?; + let description = StringArgumentType::get(context, "description").unwrap_or(name_str); + + if !is_valid_pack_name(name_str) { + return Err(ERROR_CREATE_INVALID_NAME + .create_without_context(TextComponent::text(name_str.to_string()))); + } + + let server = context.server(); + let datapacks_dir = server.basic_config.get_world_path().join("datapacks"); + let pack_dir = datapacks_dir.join(name_str); + + if pack_dir.exists() { + return Err(ERROR_CREATE_ALREADY_EXISTS + .create_without_context(TextComponent::text(name_str.to_string()))); + } + + let data_dir = pack_dir.join("data"); + if let Err(err) = fs::create_dir_all(&data_dir) { + error!("Failed to create datapack directory: {err}"); + return Err(ERROR_CREATE_IO_FAILURE + .create_without_context(TextComponent::text(name_str.to_string()))); + } + + let mcmeta_content = serde_json::json!({ + "pack": { + "pack_format": 61, + "description": description + } + }); + + let mcmeta_path = pack_dir.join("pack.mcmeta"); + let mcmeta_str = match serde_json::to_string_pretty(&mcmeta_content) { + Ok(s) => s, + Err(err) => { + error!("Failed to serialize pack.mcmeta: {err}"); + return Err(ERROR_CREATE_IO_FAILURE + .create_without_context(TextComponent::text(name_str.to_string()))); + } + }; + + if let Err(err) = fs::write(&mcmeta_path, mcmeta_str) { + error!("Failed to write pack.mcmeta: {err}"); + return Err(ERROR_CREATE_IO_FAILURE + .create_without_context(TextComponent::text(name_str.to_string()))); + } + + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_DATAPACK_CREATE_SUCCESS, + translation::java::COMMANDS_DATAPACK_CREATE_SUCCESS, + [TextComponent::text(name_str.to_string())], + ), + true, + ) + .await; + + Ok(1) + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + let list_builder = literal("list") + .then(literal("available").executes(DatapackListExecutor(ListFilter::Available))) + .then(literal("enabled").executes(DatapackListExecutor(ListFilter::Enabled))) + .executes(DatapackListExecutor(ListFilter::All)); + + let enable_builder = literal("enable").then( + argument("name", StringArgumentType::QuotablePhrase) + .suggests(AvailablePackSuggestionProvider) + .then(literal("first").executes(DatapackEnableExecutor(EnablePosition::First))) + .then(literal("last").executes(DatapackEnableExecutor(EnablePosition::Last))) + .then( + literal("before").then( + argument("existing", StringArgumentType::QuotablePhrase) + .suggests(EnabledPackSuggestionProvider) + .executes(DatapackEnableExistingExecutor(BeforeOrAfter::Before)), + ), + ) + .then( + literal("after").then( + argument("existing", StringArgumentType::QuotablePhrase) + .suggests(EnabledPackSuggestionProvider) + .executes(DatapackEnableExistingExecutor(BeforeOrAfter::After)), + ), + ) + .executes(DatapackEnableExecutor(EnablePosition::Last)), + ); + + let disable_builder = literal("disable").then( + argument("name", StringArgumentType::QuotablePhrase) + .suggests(EnabledPackSuggestionProvider) + .executes(DatapackDisableExecutor), + ); + + let create_builder = literal("create").then( + argument("name", StringArgumentType::SingleWord) + .then( + argument("description", StringArgumentType::GreedyPhrase) + .executes(DatapackCreateExecutor), + ) + .executes(DatapackCreateExecutor), + ); + + dispatcher.register( + command("datapack", DESCRIPTION) + .requires(PERMISSION) + .then(list_builder) + .then(enable_builder) + .then(disable_builder) + .then(create_builder), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_pack_name_check() { + assert!(is_valid_pack_name("my_pack")); + assert!(is_valid_pack_name("test-1.0")); + assert!(is_valid_pack_name("pack123")); + + assert!(!is_valid_pack_name("")); + assert!(!is_valid_pack_name("../hack")); + assert!(!is_valid_pack_name("folder/pack")); + assert!(!is_valid_pack_name("pack\\test")); + assert!(!is_valid_pack_name("invalid char")); + } + + #[test] + fn format_pack_brackets() { + let formatted = format_pack("vanilla"); + assert!(formatted.to_pretty_console().contains("[vanilla]")); + } + + #[test] + fn pack_mcmeta_json_structure() { + let mcmeta_content = serde_json::json!({ + "pack": { + "pack_format": 61, + "description": "Test description" + } + }); + let mcmeta_str = serde_json::to_string(&mcmeta_content).unwrap(); + assert!(mcmeta_str.contains("\"pack_format\":61")); + assert!(mcmeta_str.contains("\"description\":\"Test description\"")); + } +} diff --git a/crates/pumpkin/src/command/commands/function.rs b/crates/pumpkin/src/command/commands/function.rs new file mode 100644 index 000000000..9425e4165 --- /dev/null +++ b/crates/pumpkin/src/command/commands/function.rs @@ -0,0 +1,110 @@ +use pumpkin_data::translation; +use pumpkin_util::PermissionLvl; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::TextComponent; + +use crate::command::argument_builder::{ArgumentBuilder, argument, command}; +use crate::command::argument_types::core::string::StringArgumentType; +use crate::command::context::command_context::CommandContext; +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; + +const DESCRIPTION: &str = "Runs commands found in the corresponding function files."; +const PERMISSION: &str = "minecraft:command.function"; + +static ERROR_UNKNOWN_FUNCTION: CommandErrorType<1> = CommandErrorType::new( + translation::java::ARGUMENTS_FUNCTION_UNKNOWN, + translation::java::ARGUMENTS_FUNCTION_UNKNOWN, +); + +struct FunctionSuggestionProvider; + +impl SuggestionProvider for FunctionSuggestionProvider { + fn suggest<'a>( + &'a self, + context: &'a CommandContext, + mut builder: SuggestionsBuilder, + ) -> SuggestionProviderResult<'a> { + Box::pin(async move { + let server = context.server(); + let function_names = server.datapack_manager.get_function_names().await; + for name in function_names { + builder = builder.suggest(name); + } + builder.build() + }) + } +} + +struct FunctionExecutor; + +impl CommandExecutor for FunctionExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name_str = StringArgumentType::get(context, "name")?; + let server = context.server(); + + let Ok(executed_count) = server + .datapack_manager + .execute_function(server, &context.source, name_str) + .await + else { + return Err(ERROR_UNKNOWN_FUNCTION + .create_without_context(TextComponent::text(name_str.to_string()))); + }; + + if name_str.starts_with('#') { + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_FUNCTION_SUCCESS_MULTIPLE, + translation::java::COMMANDS_FUNCTION_SUCCESS_MULTIPLE, + [ + TextComponent::text(executed_count.to_string()), + TextComponent::text(name_str.to_string()), + ], + ), + true, + ) + .await; + } else { + context + .source + .send_feedback( + TextComponent::translate_cross( + translation::java::COMMANDS_FUNCTION_SUCCESS_SINGLE, + translation::java::COMMANDS_FUNCTION_SUCCESS_SINGLE, + [ + TextComponent::text(executed_count.to_string()), + TextComponent::text(name_str.to_string()), + ], + ), + true, + ) + .await; + } + + Ok(executed_count as i32) + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + dispatcher.register( + command("function", DESCRIPTION).requires(PERMISSION).then( + argument("name", StringArgumentType::SingleWord) + .suggests(FunctionSuggestionProvider) + .executes(FunctionExecutor), + ), + ); +} diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index d721d8ef7..a8a6804fb 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -17,6 +17,7 @@ mod clear; mod clone; mod damage; mod data; +mod datapack; mod debug; pub mod defaultgamemode; mod deop; @@ -30,6 +31,7 @@ mod fetchprofile; mod fill; mod fillbiome; mod forceload; +mod function; mod gamemode; mod gamerule; mod give; @@ -209,6 +211,8 @@ pub fn default_dispatcher( teammsg::register(&mut dispatcher, registry); clone::register(&mut dispatcher, registry); attribute::register(&mut dispatcher, registry); + datapack::register(&mut dispatcher, registry); + function::register(&mut dispatcher, registry); fetchprofile::register(&mut dispatcher, registry); apply_command_overrides(&mut dispatcher, registry, commands_config); diff --git a/crates/pumpkin/src/command/commands/place.rs b/crates/pumpkin/src/command/commands/place.rs index 0548b3ff8..249fca0e2 100644 --- a/crates/pumpkin/src/command/commands/place.rs +++ b/crates/pumpkin/src/command/commands/place.rs @@ -312,7 +312,9 @@ impl CommandExecutor for PlaceStructureExecutor { .create_without_context(TextComponent::text(structure_name.clone())) })?; - let random = RandomGenerator::Legacy(LegacyRand::from_seed(seed)); + let mut random = RandomGenerator::Legacy(LegacyRand::from_seed(seed)); + let pool_alias_lookup = + PoolAliasLookup::from_bindings(structure.pool_aliases, &mut random); let position = JigsawPlacement::add_pieces( &mut StructureGeneratorContext { @@ -334,7 +336,7 @@ impl CommandExecutor for PlaceStructureExecutor { &MaxDistance::new(structure.max_distance_from_center.unwrap_or(128)), DimensionPadding::ZERO, LiquidSettings::ApplyWaterlog, - &PoolAliasLookup::default(), + &pool_alias_lookup, ) .ok_or_else(|| { JIGSAW_FAILED diff --git a/crates/pumpkin/src/data/datapack/function_loader.rs b/crates/pumpkin/src/data/datapack/function_loader.rs new file mode 100644 index 000000000..b0d2027aa --- /dev/null +++ b/crates/pumpkin/src/data/datapack/function_loader.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +pub fn load_functions_from_dir( + namespace: &str, + function_dir: &Path, + functions: &mut HashMap, S>, +) { + if !function_dir.is_dir() { + return; + } + load_functions_recursive(namespace, function_dir, function_dir, functions); +} + +fn load_functions_recursive( + namespace: &str, + base_dir: &Path, + current_dir: &Path, + functions: &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_functions_recursive(namespace, base_dir, &path, functions); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("mcfunction")) + && let Ok(rel_path) = path.strip_prefix(base_dir) + { + let mut stem_path = rel_path.to_string_lossy().to_string(); + if let Some(s) = stem_path.strip_suffix(".mcfunction") { + stem_path = s.to_string(); + } + // Convert Windows backslashes to forward slashes if any + let stem_path = stem_path.replace('\\', "/"); + let function_id = format!("{namespace}:{stem_path}"); + if let Ok(content) = fs::read_to_string(&path) { + let lines: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.strip_prefix('/').unwrap_or(line).to_string()) + .collect(); + functions.insert(function_id, lines); + } + } + } +} + +pub fn load_function_tags_from_dir( + namespace: &str, + tags_dir: &Path, + function_tags: &mut HashMap, S>, +) { + let function_tags_dir = tags_dir.join("function"); + let function_tags_dir_plural = tags_dir.join("functions"); + for dir in [&function_tags_dir, &function_tags_dir_plural] { + if dir.is_dir() { + load_tags_recursive(namespace, dir, dir, function_tags); + } + } +} + +fn load_tags_recursive( + namespace: &str, + base_dir: &Path, + current_dir: &Path, + tags: &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_tags_recursive(namespace, base_dir, &path, tags); + } 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(s) = stem_path.strip_suffix(".json") { + stem_path = s.to_string(); + } + let stem_path = stem_path.replace('\\', "/"); + let tag_id = format!("{namespace}:{stem_path}"); + if let Ok(content) = fs::read_to_string(&path) + && let Ok(val) = serde_json::from_str::(&content) + && let Some(values_arr) = val.get("values").and_then(serde_json::Value::as_array) + { + let list: Vec = values_arr + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + tags.entry(tag_id).or_default().extend(list); + } + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn parse_mcfunction_content() { + let content = "# This is a comment\nsay Hello world\n/give @p diamond 1\n\n# Another comment\ngive @p stick 5\n"; + let lines: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.strip_prefix('/').unwrap_or(line).to_string()) + .collect(); + + assert_eq!( + lines, + vec![ + "say Hello world".to_string(), + "give @p diamond 1".to_string(), + "give @p stick 5".to_string() + ] + ); + } +} diff --git a/crates/pumpkin/src/data/datapack/mod.rs b/crates/pumpkin/src/data/datapack/mod.rs new file mode 100644 index 000000000..41ca7edea --- /dev/null +++ b/crates/pumpkin/src/data/datapack/mod.rs @@ -0,0 +1,275 @@ +pub mod function_loader; +pub mod recipe_loader; + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use pumpkin_protocol::codec::recipe::DynamicRecipe; + +use crate::command::context::command_source::CommandSource; +use crate::server::Server; +use crate::server::recipe::RecipeManager; + +#[derive(Clone, Debug)] +pub struct LoadedDatapack { + pub id: String, + pub name: String, + pub description: String, + pub pack_format: u32, + pub root_path: PathBuf, + pub recipe_count: usize, + pub function_count: usize, +} + +pub struct DatapackManager { + loaded_packs: RwLock>, + functions: RwLock>>, + function_tags: RwLock>>, +} + +impl Default for DatapackManager { + fn default() -> Self { + Self::new() + } +} + +impl DatapackManager { + #[must_use] + pub fn new() -> Self { + Self { + loaded_packs: RwLock::new(Vec::new()), + functions: RwLock::new(HashMap::new()), + function_tags: RwLock::new(HashMap::new()), + } + } + + pub async fn load_all( + &self, + world_path: &Path, + enabled_packs: &[String], + recipe_manager: &RecipeManager, + ) { + let datapacks_dir = world_path.join("datapacks"); + let mut loaded_packs_vec = Vec::new(); + let mut all_recipes: Vec = Vec::new(); + let mut all_functions: HashMap> = HashMap::new(); + let mut all_function_tags: HashMap> = HashMap::new(); + + if datapacks_dir.is_dir() { + let Ok(entries) = fs::read_dir(&datapacks_dir) else { + warn!( + "Failed to read datapacks directory: {}", + 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) = read_pack_mcmeta(&pack_path); + + let data_dir = pack_path.join("data"); + let mut pack_recipe_count = 0; + let mut pack_function_count = 0; + + if data_dir.is_dir() + && let Ok(ns_entries) = fs::read_dir(&data_dir) + { + for ns_entry in ns_entries.flatten() { + let ns_path = ns_entry.path(); + if !ns_path.is_dir() { + continue; + } + let namespace = ns_entry.file_name().to_string_lossy().to_string(); + + // Load recipes + for recipe_sub in ["recipe", "recipes"] { + let recipe_dir = ns_path.join(recipe_sub); + if recipe_dir.is_dir() { + load_recipes_from_dir( + &namespace, + &recipe_dir, + &mut all_recipes, + &mut pack_recipe_count, + ); + } + } + + // Load functions + 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, + &mut all_functions, + ); + pack_function_count += all_functions.len() - before; + } + } + + // Load tags + let tags_dir = ns_path.join("tags"); + if tags_dir.is_dir() { + function_loader::load_function_tags_from_dir( + &namespace, + &tags_dir, + &mut all_function_tags, + ); + } + } + } + + info!( + "Loaded datapack '{file_name}': {pack_recipe_count} recipe(s), {pack_function_count} function(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, + }); + } + } + + recipe_manager.set_recipes(all_recipes).await; + *self.loaded_packs.write().await = loaded_packs_vec; + *self.functions.write().await = all_functions; + *self.function_tags.write().await = all_function_tags; + } + + pub async fn get_loaded_packs(&self) -> Vec { + self.loaded_packs.read().await.clone() + } + + pub async fn get_functions(&self) -> HashMap> { + self.functions.read().await.clone() + } + + pub async fn get_function_names(&self) -> Vec { + let fns = self.functions.read().await; + let tags = self.function_tags.read().await; + let mut names = Vec::with_capacity(fns.len() + tags.len()); + names.extend(fns.keys().cloned()); + for tag in tags.keys() { + names.push(format!("#{tag}")); + } + names + } + + pub async fn execute_function( + &self, + server: &Arc, + source: &CommandSource, + name: &str, + ) -> Result { + let (functions_to_run, is_tag) = if let Some(tag_name) = name.strip_prefix('#') { + let tags = self.function_tags.read().await; + let Some(fns) = tags.get(tag_name) else { + return Err(format!("Unknown function tag: #{tag_name}")); + }; + (fns.clone(), true) + } else { + (vec![name.to_string()], false) + }; + + let all_fns = self.functions.read().await; + let mut total_executed = 0; + + for fn_id in functions_to_run { + let Some(lines) = all_fns.get(&fn_id) else { + if !is_tag { + return Err(format!("Unknown function: {fn_id}")); + } + continue; + }; + + for line in lines { + server + .command_dispatcher + .load() + .handle_command(source, line) + .await; + total_executed += 1; + } + } + + Ok(total_executed) + } +} + +fn read_pack_mcmeta(pack_path: &Path) -> (String, u32) { + let mcmeta_path = pack_path.join("pack.mcmeta"); + if let Ok(content) = fs::read_to_string(mcmeta_path) + && let Ok(val) = serde_json::from_str::(&content) + { + let pack = val.get("pack"); + let description = pack + .and_then(|p| p.get("description")) + .map(|d| { + d.as_str() + .map_or_else(|| d.to_string(), ToString::to_string) + }) + .unwrap_or_default(); + let pack_format = pack + .and_then(|p| p.get("pack_format")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(61) as u32; + return (description, pack_format); + } + (String::new(), 61) +} + +fn load_recipes_from_dir( + namespace: &str, + dir: &Path, + all_recipes: &mut Vec, + count: &mut usize, +) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + load_recipes_from_dir(namespace, &path, all_recipes, count); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + { + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Ok(content) = fs::read_to_string(&path) + && let Some(recipe) = recipe_loader::parse_recipe(namespace, &stem, &content) + { + all_recipes.push(recipe); + *count += 1; + } + } + } +} diff --git a/crates/pumpkin/src/data/datapack/recipe_loader.rs b/crates/pumpkin/src/data/datapack/recipe_loader.rs new file mode 100644 index 000000000..a6ca6e253 --- /dev/null +++ b/crates/pumpkin/src/data/datapack/recipe_loader.rs @@ -0,0 +1,294 @@ +use pumpkin_data::recipes::RecipeCategoryTypes; +use pumpkin_protocol::codec::recipe::{ + DynamicRecipe, OwnedCookingRecipe, OwnedCookingRecipeType, OwnedCraftingRecipe, + OwnedRecipeIngredient, OwnedRecipeResult, +}; +use serde_json::Value; + +pub fn parse_recipe(namespace: &str, name: &str, json_str: &str) -> Option { + let value: Value = serde_json::from_str(json_str).ok()?; + let recipe_id = format!("{namespace}:{name}"); + let raw_type = value.get("type")?.as_str()?; + let recipe_type = raw_type.strip_prefix("minecraft:").unwrap_or(raw_type); + + match recipe_type { + "crafting_shaped" => parse_shaped(recipe_id, &value).map(DynamicRecipe::Crafting), + "crafting_shapeless" => parse_shapeless(recipe_id, &value).map(DynamicRecipe::Crafting), + "smelting" => parse_cooking(recipe_id, &value, 200) + .map(|c| DynamicRecipe::Cooking(OwnedCookingRecipeType::Smelting(c))), + "blasting" => parse_cooking(recipe_id, &value, 100) + .map(|c| DynamicRecipe::Cooking(OwnedCookingRecipeType::Blasting(c))), + "smoking" => parse_cooking(recipe_id, &value, 100) + .map(|c| DynamicRecipe::Cooking(OwnedCookingRecipeType::Smoking(c))), + "campfire_cooking" => parse_cooking(recipe_id, &value, 600) + .map(|c| DynamicRecipe::Cooking(OwnedCookingRecipeType::CampfireCooking(c))), + _ => None, + } +} + +fn normalize_id(id: &str) -> String { + if id.contains(':') { + id.to_string() + } else { + format!("minecraft:{id}") + } +} + +fn parse_ingredient(value: &Value) -> Option { + match value { + Value::String(s) => Some(OwnedRecipeIngredient::Simple(normalize_id(s))), + Value::Object(map) => map + .get("tag") + .and_then(Value::as_str) + .map(|tag| OwnedRecipeIngredient::Tagged(normalize_id(tag))) + .or_else(|| { + map.get("item") + .and_then(Value::as_str) + .map(|item| OwnedRecipeIngredient::Simple(normalize_id(item))) + }), + Value::Array(arr) => { + let mut ids = Vec::new(); + for item in arr { + if let Some(s) = item.as_str() { + ids.push(normalize_id(s)); + } else if let Some(obj_item) = item.get("item").and_then(Value::as_str) { + ids.push(normalize_id(obj_item)); + } + } + if ids.is_empty() { + None + } else { + Some(OwnedRecipeIngredient::OneOf(ids)) + } + } + _ => None, + } +} + +fn parse_result(value: &Value) -> Option { + match value { + Value::String(s) => Some(OwnedRecipeResult { + item_id: normalize_id(s), + count: 1, + }), + Value::Object(map) => { + let id = map + .get("id") + .or_else(|| map.get("item")) + .and_then(Value::as_str)?; + let count = map + .get("count") + .and_then(Value::as_u64) + .unwrap_or(1) + .min(99) as u8; + Some(OwnedRecipeResult { + item_id: normalize_id(id), + count, + }) + } + _ => None, + } +} + +fn parse_category(value: Option<&Value>) -> RecipeCategoryTypes { + let Some(val) = value.and_then(Value::as_str) else { + return RecipeCategoryTypes::Misc; + }; + match val.to_ascii_lowercase().as_str() { + "equipment" => RecipeCategoryTypes::Equipment, + "building" => RecipeCategoryTypes::Building, + "redstone" | "restone" => RecipeCategoryTypes::Restone, + "food" => RecipeCategoryTypes::Food, + "blocks" => RecipeCategoryTypes::Blocks, + _ => RecipeCategoryTypes::Misc, + } +} + +fn parse_shaped(recipe_id: String, value: &Value) -> Option { + let pattern = value + .get("pattern")? + .as_array()? + .iter() + .map(|v| v.as_str().map(ToString::to_string)) + .collect::>>()?; + + let key_map = value.get("key")?.as_object()?; + let mut key = Vec::new(); + for (k, v) in key_map { + let ch = k.chars().next()?; + let ingredient = parse_ingredient(v)?; + key.push((ch, ingredient)); + } + + let result = parse_result(value.get("result")?)?; + let category = parse_category(value.get("category")); + let group = value + .get("group") + .and_then(Value::as_str) + .map(ToString::to_string); + let show_notification = value + .get("show_notification") + .and_then(Value::as_bool) + .unwrap_or(true); + + Some(OwnedCraftingRecipe::Shaped { + recipe_id: Some(recipe_id), + category, + group, + show_notification, + key, + pattern, + result, + }) +} + +fn parse_shapeless(recipe_id: String, value: &Value) -> Option { + let ingredients_arr = value.get("ingredients")?.as_array()?; + let mut ingredients = Vec::new(); + for ing_val in ingredients_arr { + ingredients.push(parse_ingredient(ing_val)?); + } + + let result = parse_result(value.get("result")?)?; + let category = parse_category(value.get("category")); + let group = value + .get("group") + .and_then(Value::as_str) + .map(ToString::to_string); + + Some(OwnedCraftingRecipe::Shapeless { + recipe_id: Some(recipe_id), + category, + group, + ingredients, + result, + }) +} + +fn parse_cooking( + recipe_id: String, + value: &Value, + default_cooking_time: i32, +) -> Option { + let ingredient = parse_ingredient(value.get("ingredient")?)?; + let result = parse_result(value.get("result")?)?; + let category = parse_category(value.get("category")); + let group = value + .get("group") + .and_then(Value::as_str) + .map(ToString::to_string); + let cooking_time = value + .get("cookingtime") + .and_then(Value::as_i64) + .map_or(default_cooking_time, |t| t as i32); + let experience = value + .get("experience") + .and_then(Value::as_f64) + .map_or(0.0, |e| e as f32); + + Some(OwnedCookingRecipe { + recipe_id, + category, + group, + ingredient, + cooking_time, + experience, + result, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_shaped_recipe() { + let json = r##"{ + "type": "minecraft:crafting_shaped", + "group": "sword", + "pattern": ["X", "X", "#"], + "key": { + "#": "minecraft:stick", + "X": "minecraft:diamond" + }, + "result": { + "id": "minecraft:diamond_sword", + "count": 1 + } + }"##; + + let parsed = parse_recipe("test", "diamond_sword", json); + assert!(parsed.is_some()); + if let Some(DynamicRecipe::Crafting(OwnedCraftingRecipe::Shaped { + recipe_id, + result, + pattern, + .. + })) = parsed + { + assert_eq!(recipe_id.as_deref(), Some("test:diamond_sword")); + assert_eq!(result.item_id, "minecraft:diamond_sword"); + assert_eq!(pattern.len(), 3); + } else { + panic!("Expected shaped recipe"); + } + } + + #[test] + fn parse_shapeless_recipe() { + let json = r#"{ + "type": "minecraft:crafting_shapeless", + "ingredients": ["minecraft:oak_log"], + "result": { + "id": "minecraft:oak_planks", + "count": 4 + } + }"#; + + let parsed = parse_recipe("minecraft", "oak_planks_from_oak_log", json); + assert!(parsed.is_some()); + if let Some(DynamicRecipe::Crafting(OwnedCraftingRecipe::Shapeless { + recipe_id, + result, + ingredients, + .. + })) = parsed + { + assert_eq!( + recipe_id.as_deref(), + Some("minecraft:oak_planks_from_oak_log") + ); + assert_eq!(result.item_id, "minecraft:oak_planks"); + assert_eq!(result.count, 4); + assert_eq!(ingredients.len(), 1); + } else { + panic!("Expected shapeless recipe"); + } + } + + #[test] + fn parse_smelting_recipe() { + let json = r#"{ + "type": "minecraft:smelting", + "ingredient": "minecraft:raw_iron", + "result": { + "id": "minecraft:iron_ingot" + }, + "experience": 0.7, + "cookingtime": 200 + }"#; + + let parsed = parse_recipe("minecraft", "iron_ingot_from_smelting_raw_iron", json); + assert!(parsed.is_some()); + if let Some(DynamicRecipe::Cooking(OwnedCookingRecipeType::Smelting(cooking))) = parsed { + assert_eq!( + cooking.recipe_id, + "minecraft:iron_ingot_from_smelting_raw_iron" + ); + assert_eq!(cooking.result.item_id, "minecraft:iron_ingot"); + assert_eq!(cooking.cooking_time, 200); + } else { + panic!("Expected smelting recipe"); + } + } +} diff --git a/crates/pumpkin/src/data/mod.rs b/crates/pumpkin/src/data/mod.rs index 351d4de3a..1d4fee074 100644 --- a/crates/pumpkin/src/data/mod.rs +++ b/crates/pumpkin/src/data/mod.rs @@ -12,6 +12,7 @@ pub mod advancement_data; pub mod banlist_serializer; pub mod banned_ip; pub mod banned_player; +pub mod datapack; pub mod player_server; pub mod usercache; pub mod whitelist; diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs index 6c1aa4597..ba74f953a 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -106,6 +106,7 @@ pub struct Server { /// Assigns unique IDs to containers. container_id: AtomicU32, pub recipe_manager: Arc, + pub datapack_manager: Arc, pub enchantment_manager: Arc, /// Assigns unique IDs to maps. map_id: AtomicI32, @@ -278,6 +279,7 @@ impl Server { permission_manager, container_id: 0.into(), recipe_manager: Arc::new(recipe::RecipeManager::new()), + datapack_manager: Arc::new(crate::data::datapack::DatapackManager::new()), enchantment_manager: Arc::new(enchantment::EnchantmentManager::new()), map_id: level_info.load().map_id.into(), worlds: ArcSwap::from_pointee(vec![]), @@ -446,6 +448,24 @@ impl Server { server.worlds.store(Arc::new(worlds_vec)); info!("All worlds loaded successfully."); + + let enabled_packs = server.level_info.load().data_packs.enabled.clone(); + server + .datapack_manager + .load_all(&world_path, &enabled_packs, &server.recipe_manager) + .await; + + let server_for_load = server.clone(); + tokio::spawn(async move { + let source = crate::command::CommandSender::Console + .into_source(&server_for_load) + .await; + let _ = server_for_load + .datapack_manager + .execute_function(&server_for_load, &source, "#minecraft:load") + .await; + }); + server } @@ -559,7 +579,47 @@ impl Server { Ok(()) } + pub fn save_world_info(&self) -> Result<(), WorldInfoError> { + let level_data = self.level_info.load(); + self.world_info_writer + .write_world_info(&level_data, &self.basic_config.get_world_path()) + } + + pub async fn reload_datapacks(&self, server: &Arc) { + let enabled_packs = self.level_info.load().data_packs.enabled.clone(); + let world_path = self.basic_config.get_world_path(); + self.datapack_manager + .load_all(&world_path, &enabled_packs, &self.recipe_manager) + .await; + + let source = crate::command::CommandSender::Console + .into_source(server) + .await; + let _ = self + .datapack_manager + .execute_function(server, &source, "#minecraft:load") + .await; + + let dynamic_recipes = self.recipe_manager.get_dynamic_recipes_internal().await; + for player in self.get_all_players() { + if let crate::net::ClientPlatform::Java(java_client) = player.client.as_ref() { + let add_packet = pumpkin_protocol::java::client::play::CRecipeBookAdd::new( + true, + &dynamic_recipes, + ); + if let Ok(data) = java_client.serialize_packet(&add_packet) { + java_client.send_packet_now(data).await; + } + } + } + } + pub async fn save_all(&self) -> Result<(), String> { + if let Err(err) = self.save_world_info() { + error!("Failed to save world info: {err}"); + return Err(format!("Failed to save world info: {err}")); + } + if let Err(err) = self.player_data_storage.save_all_players(self).await { error!("Failed to save player data: {err}"); return Err(format!("Failed to save player data: {err}")); diff --git a/crates/pumpkin/src/server/recipe.rs b/crates/pumpkin/src/server/recipe.rs index 17c6e87de..0e3934b9e 100644 --- a/crates/pumpkin/src/server/recipe.rs +++ b/crates/pumpkin/src/server/recipe.rs @@ -26,6 +26,21 @@ impl RecipeManager { recipes.push(recipe); } + pub async fn add_recipes(&self, new_recipes: impl IntoIterator) { + let mut recipes = self.dynamic_recipes.write().await; + recipes.extend(new_recipes); + } + + pub async fn set_recipes(&self, new_recipes: Vec) { + let mut recipes = self.dynamic_recipes.write().await; + *recipes = new_recipes; + } + + pub async fn clear(&self) { + let mut recipes = self.dynamic_recipes.write().await; + recipes.clear(); + } + pub async fn get_dynamic_recipes_internal(&self) -> Vec { self.dynamic_recipes.read().await.clone() } diff --git a/tools/pumpkin-codegen/src/structures.rs b/tools/pumpkin-codegen/src/structures.rs index 30f0d9e3a..5af45a8cb 100644 --- a/tools/pumpkin-codegen/src/structures.rs +++ b/tools/pumpkin-codegen/src/structures.rs @@ -73,6 +73,54 @@ pub enum StructurePlacementTypeStruct { }, } +/// Deserialized structure pool alias binding. +#[derive(Deserialize, Clone)] +#[serde(tag = "type")] +pub enum PoolAliasBindingStruct { + #[serde(rename = "minecraft:direct")] + Direct { alias: String, target: String }, + #[serde(rename = "minecraft:random")] + Random { + alias: String, + targets: Vec, + }, + #[serde(rename = "minecraft:random_group")] + RandomGroup { groups: Vec }, +} + +#[derive(Deserialize, Clone)] +pub struct WeightedTargetStruct { + pub data: String, + pub weight: u32, +} + +#[derive(Deserialize, Clone)] +pub struct WeightedGroupStruct { + pub data: Vec, + pub weight: u32, +} + +#[derive(Deserialize, Clone)] +pub struct SpawnOverrideStruct { + pub bounding_box: String, + pub spawns: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct SpawnEntryStruct { + #[serde(rename = "type")] + pub entity_type: String, + #[serde(default = "default_spawn_count", rename = "minCount")] + pub min_count: u32, + #[serde(default = "default_spawn_count", rename = "maxCount")] + pub max_count: u32, + pub weight: u32, +} + +fn default_spawn_count() -> u32 { + 1 +} + /// Deserialized structure entry specifying its valid biomes and generation step. #[derive(Deserialize, Clone)] pub struct StructureStruct { @@ -90,6 +138,8 @@ pub struct StructureStruct { pub terrain_adaptation: Option, /// Optional jigsaw start height. pub start_height: Option, + /// Optional height provider (e.g. for nether fossils). + pub height: Option, /// Optional heightmap to project the start to. pub project_start_to_heightmap: Option, /// Optional max distance from center. @@ -100,6 +150,22 @@ pub struct StructureStruct { pub dimension_padding: Option, /// Critical for villages to appropriately truncate long village streets. pub use_expansion_hack: Option, + /// Optional pool alias bindings (e.g. for Trial Chambers). + #[serde(default)] + pub pool_aliases: Vec, + /// Spawn overrides (e.g. for Ocean Monuments). + #[serde(default)] + pub spawn_overrides: BTreeMap, + /// Optional beached flag for shipwrecks. + pub is_beached: Option, + /// Optional mineshaft variant ("normal" or "mesa"). + pub mineshaft_type: Option, + /// Optional ocean ruin temperature ("cold" or "warm"). + pub biome_temp: Option, + /// Optional cluster probability for ocean ruins. + pub cluster_probability: Option, + /// Optional large probability for ocean ruins. + pub large_probability: Option, /// Defines the generation behavior (e.g. "minecraft:jigsaw"). #[serde(rename = "type")] pub structure_type: String, @@ -216,6 +282,62 @@ impl ToTokens for StructurePlacementTypeStruct { } } +impl ToTokens for PoolAliasBindingStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::Direct { alias, target } => { + tokens.extend(quote!( + PoolAliasBinding::Direct { + alias: #alias, + target: #target, + } + )); + } + Self::Random { alias, targets } => { + tokens.extend(quote!( + PoolAliasBinding::Random { + alias: #alias, + targets: &[#(#targets),*], + } + )); + } + Self::RandomGroup { groups } => { + tokens.extend(quote!( + PoolAliasBinding::RandomGroup { + groups: &[#(#groups),*], + } + )); + } + } + } +} + +impl ToTokens for WeightedTargetStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + let target = &self.data; + let weight = self.weight; + tokens.extend(quote!( + WeightedPoolAliasTarget { + target: #target, + weight: #weight, + } + )); + } +} + +impl ToTokens for WeightedGroupStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + let bindings = &self.data; + let weight = self.weight; + tokens.extend(quote!( + WeightedPoolAliasGroup { + bindings: &[#(#bindings),*], + weight: #weight, + } + )); + } +} + impl ToTokens for StructureStruct { fn to_tokens(&self, tokens: &mut TokenStream) { let biomes = &self.biomes; @@ -266,27 +388,76 @@ impl ToTokens for StructureStruct { } else { quote!(None) }; + let pool_aliases = &self.pool_aliases; - let structure_type = structure_type_to_token(&self.structure_type); - - let start_height = if let Some(sh) = &self.start_height { - if let Some(abs) = sh.get("absolute") { - let abs_val = abs.as_i64().unwrap() as i16; - quote!(Some(#abs_val)) - } else if let Some(min_inc) = sh.get("min_inclusive") { - if let Some(abs) = min_inc.get("absolute") { - let abs_val = abs.as_i64().unwrap() as i16; - quote!(Some(#abs_val)) - } else { - quote!(None) - } - } else { - quote!(None) - } + let height_val = self.start_height.as_ref().or(self.height.as_ref()); + let start_height = if let Some(hv) = height_val { + let hp = value_to_height_provider(hv); + quote!(Some(#hp)) } else { quote!(None) }; + let spawn_overrides: Vec = self + .spawn_overrides + .iter() + .map(|(category, so)| { + let bbox = match so.bounding_box.as_str() { + "piece" => quote!(BoundingBoxType::Piece), + _ => quote!(BoundingBoxType::Full), + }; + let spawns: Vec = so + .spawns + .iter() + .map(|entry| { + let et = &entry.entity_type; + let min = entry.min_count; + let max = entry.max_count; + let weight = entry.weight; + quote!(SpawnEntry { + entity_type: #et, + min_count: #min, + max_count: #max, + weight: #weight, + }) + }) + .collect(); + quote!(SpawnOverride { + category: #category, + bounding_box: #bbox, + spawns: &[#(#spawns),*], + }) + }) + .collect(); + + let is_beached = if let Some(ib) = self.is_beached { + quote!(Some(#ib)) + } else { + quote!(None) + }; + let mineshaft_type = if let Some(mt) = &self.mineshaft_type { + quote!(Some(#mt)) + } else { + quote!(None) + }; + let biome_temp = if let Some(bt) = &self.biome_temp { + quote!(Some(#bt)) + } else { + quote!(None) + }; + let cluster_probability = if let Some(cp) = self.cluster_probability { + quote!(Some(#cp)) + } else { + quote!(None) + }; + let large_probability = if let Some(lp) = self.large_probability { + quote!(Some(#lp)) + } else { + quote!(None) + }; + + let structure_type = structure_type_to_token(&self.structure_type); + tokens.extend(quote!( Structure { biomes: #biomes, @@ -301,12 +472,102 @@ impl ToTokens for StructureStruct { liquid_settings: #liquid_settings, dimension_padding: #dimension_padding, use_expansion_hack: #use_expansion_hack, + pool_aliases: &[#(#pool_aliases),*], + spawn_overrides: &[#(#spawn_overrides),*], + is_beached: #is_beached, + mineshaft_type: #mineshaft_type, + biome_temp: #biome_temp, + cluster_probability: #cluster_probability, + large_probability: #large_probability, structure_type: #structure_type, } )); } } +fn value_to_height_provider(v: &serde_json::Value) -> TokenStream { + if let Some(type_str) = v.get("type").and_then(|t| t.as_str()) { + match type_str { + "minecraft:uniform" => { + let min = value_to_y_offset(&v["min_inclusive"]); + let max = value_to_y_offset(&v["max_inclusive"]); + quote!(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: #min, + max_inclusive: #max, + })) + } + "minecraft:trapezoid" => { + let min = value_to_y_offset(&v["min_inclusive"]); + let max = value_to_y_offset(&v["max_inclusive"]); + let plateau = v.get("plateau").and_then(|p| p.as_i64()).map(|p| p as i32); + let plateau_token = if let Some(p) = plateau { + quote!(Some(#p)) + } else { + quote!(None) + }; + quote!(HeightProvider::Trapezoid(TrapezoidHeightProvider { + min_inclusive: #min, + max_inclusive: #max, + plateau: #plateau_token, + })) + } + "minecraft:very_biased_to_bottom" => { + let min = value_to_y_offset(&v["min_inclusive"]); + let max = value_to_y_offset(&v["max_inclusive"]); + let inner = v.get("inner").and_then(|i| i.as_u64()).map(|i| i as u32); + let inner_token = if let Some(i) = inner { + quote!(std::num::NonZero::new(#i)) + } else { + quote!(None) + }; + quote!(HeightProvider::VeryBiasedToBottom(VeryBiasedToBottomHeightProvider { + min_inclusive: #min, + max_inclusive: #max, + inner: #inner_token, + })) + } + "minecraft:constant" => { + let y = value_to_y_offset(&v["value"]); + quote!(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: #y, + max_inclusive: #y, + })) + } + _ => { + let y = value_to_y_offset(v); + quote!(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: #y, + max_inclusive: #y, + })) + } + } + } else { + let y = value_to_y_offset(v); + quote!(HeightProvider::Uniform(UniformHeightProvider { + min_inclusive: #y, + max_inclusive: #y, + })) + } +} + +fn value_to_y_offset(v: &serde_json::Value) -> TokenStream { + if let Some(abs) = v.get("absolute") { + let val = abs.as_i64().unwrap_or(0) as i16; + quote!(YOffset::Absolute(Absolute { absolute: #val })) + } else if let Some(ab) = v.get("above_bottom") { + let val = ab.as_i64().unwrap_or(0) as i8; + quote!(YOffset::AboveBottom(AboveBottom { above_bottom: #val })) + } else if let Some(bt) = v.get("below_top") { + let val = bt.as_i64().unwrap_or(0) as i8; + quote!(YOffset::BelowTop(BelowTop { below_top: #val })) + } else if let Some(val) = v.as_i64() { + let val = val as i16; + quote!(YOffset::Absolute(Absolute { absolute: #val })) + } else { + quote!(YOffset::Absolute(Absolute { absolute: 0 })) + } +} + // Helper functions fn structure_type_to_token(st: &str) -> TokenStream { @@ -522,6 +783,7 @@ pub fn build() -> TokenStream { RandomGenerator, RandomImpl, get_carver_seed, get_region_seed, legacy_rand::LegacyRand, xoroshiro128::Xoroshiro, }; + use pumpkin_util::y_offset::{AboveBottom, Absolute, BelowTop, YOffset}; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum StructureKeys { @@ -681,6 +943,145 @@ pub fn build() -> TokenStream { Unknown, } + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum BoundingBoxType { + Piece, + Full, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct SpawnEntry { + pub entity_type: &'static str, + pub min_count: u32, + pub max_count: u32, + pub weight: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct SpawnOverride { + pub category: &'static str, + pub bounding_box: BoundingBoxType, + pub spawns: &'static [SpawnEntry], + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum HeightProvider { + Uniform(UniformHeightProvider), + Trapezoid(TrapezoidHeightProvider), + VeryBiasedToBottom(VeryBiasedToBottomHeightProvider), + } + + impl HeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + match self { + Self::Uniform(provider) => provider.get(random, min_y, height), + Self::Trapezoid(provider) => provider.get(random, min_y, height), + Self::VeryBiasedToBottom(provider) => provider.get(random, min_y, height), + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct UniformHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, + } + + impl UniformHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let min = self.min_inclusive.get_y(min_y as i16, height); + let max = self.max_inclusive.get_y(min_y as i16, height); + if min >= max { + min + } else { + random.next_inbetween_i32(min, max) + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct TrapezoidHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, + pub plateau: Option, + } + + impl TrapezoidHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let plateau = self.plateau.unwrap_or(0); + let i = self.min_inclusive.get_y(min_y as i16, height); + let j = self.max_inclusive.get_y(min_y as i16, height); + + if i >= j { + return i; + } + + let k = j - i; + if plateau >= k { + return random.next_inbetween_i32(i, j); + } + + let l = (k - plateau) / 2; + let m = k - l; + i + random.next_inbetween_i32(0, m) + random.next_inbetween_i32(0, l) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct VeryBiasedToBottomHeightProvider { + pub min_inclusive: YOffset, + pub max_inclusive: YOffset, + pub inner: Option>, + } + + impl VeryBiasedToBottomHeightProvider { + #[must_use] + pub fn get(&self, random: &mut RandomGenerator, min_y: i8, height: u16) -> i32 { + let min = self.min_inclusive.get_y(min_y as i16, height); + let max = self.max_inclusive.get_y(min_y as i16, height); + let inner = self.inner.map_or(1, std::num::NonZero::get) as i32; + + if min >= max { + return min; + } + + let min_rnd = random.next_inbetween_i32(min + inner, max); + let max_rnd = random.next_inbetween_i32(min, min_rnd - 1); + + random.next_inbetween_i32(min, max_rnd - 1 + inner) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum PoolAliasBinding { + Direct { + alias: &'static str, + target: &'static str, + }, + Random { + alias: &'static str, + targets: &'static [WeightedPoolAliasTarget], + }, + RandomGroup { + groups: &'static [WeightedPoolAliasGroup], + }, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct WeightedPoolAliasTarget { + pub target: &'static str, + pub weight: u32, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct WeightedPoolAliasGroup { + pub bindings: &'static [PoolAliasBinding], + pub weight: u32, + } + pub struct Structure { pub biomes: &'static str, pub step: GenerationStep, @@ -688,12 +1089,19 @@ pub fn build() -> TokenStream { pub start_jigsaw_name: Option<&'static str>, pub size: Option, pub terrain_adaptation: TerrainAdaptation, - pub start_height: Option, + pub start_height: Option, pub project_start_to_heightmap: Option<&'static str>, pub max_distance_from_center: Option, pub liquid_settings: Option<&'static str>, pub dimension_padding: Option, pub use_expansion_hack: Option, + pub pool_aliases: &'static [PoolAliasBinding], + pub spawn_overrides: &'static [SpawnOverride], + pub is_beached: Option, + pub mineshaft_type: Option<&'static str>, + pub biome_temp: Option<&'static str>, + pub cluster_probability: Option, + pub large_probability: Option, pub structure_type: StructureType, }