diff --git a/crates/pumpkin-data/src/generated/structures.rs b/crates/pumpkin-data/src/generated/structures.rs index 84de0f865..225a07091 100644 --- a/crates/pumpkin-data/src/generated/structures.rs +++ b/crates/pumpkin-data/src/generated/structures.rs @@ -1279,6 +1279,29 @@ impl StructureSet { Self::VILLAGES, Self::WOODLAND_MANSIONS, ]; + #[doc = r" The registry names of all structure sets, in the same order as [`Self::ALL`]."] + pub const NAMES: &'static [&'static str] = &[ + "ancient_cities", + "buried_treasures", + "desert_pyramids", + "end_cities", + "igloos", + "jungle_temples", + "mineshafts", + "nether_complexes", + "nether_fossils", + "ocean_monuments", + "ocean_ruins", + "pillager_outposts", + "ruined_portals", + "shipwrecks", + "strongholds", + "swamp_huts", + "trail_ruins", + "trial_chambers", + "villages", + "woodland_mansions", + ]; #[must_use] pub fn get(name: &str) -> Option<&'static Self> { match name { diff --git a/crates/pumpkin-world/src/generation/generator/biome_finder.rs b/crates/pumpkin-world/src/generation/generator/biome_finder.rs new file mode 100644 index 000000000..8ba856a3c --- /dev/null +++ b/crates/pumpkin-world/src/generation/generator/biome_finder.rs @@ -0,0 +1,297 @@ +use pumpkin_data::chunk::{Biome, BiomeTree, NETHER_BIOME_SOURCE, OVERWORLD_BIOME_SOURCE}; +use pumpkin_data::dimension::Dimension; +use pumpkin_util::math::position::BlockPos; +use rustc_hash::FxHashSet; + +use crate::biome::{BiomeSupplier, MultiNoiseBiomeSupplier, end::TheEndBiomeSupplier}; +use crate::generation::biome_coords; +use crate::generation::noise::router::multi_noise_sampler::{ + MultiNoiseSampler, MultiNoiseSamplerBuilderOptions, +}; + +use super::{VanillaGenerator, WorldGenerator}; + +/// Finds the closest position whose sampled biome id is contained in +/// `targets`, mirroring vanilla's `BiomeSource.findClosestBiome3d`. +/// +/// The search spirals outwards from `origin` ring by ring in steps of +/// `horizontal_step` blocks up to `horizontal_radius`, probing each column at +/// y levels spreading out from the origin y in steps of `vertical_step` +/// (staying within the dimension's build height). The first match wins, which +/// approximates the nearest one just like vanilla. +/// +/// Returns the block position of the sample point plus the concrete biome +/// found there. +#[must_use] +#[expect(clippy::implicit_hasher)] +pub fn find_closest_biome_3d( + world_gen: &WorldGenerator, + origin: BlockPos, + targets: &FxHashSet, + horizontal_radius: i32, + horizontal_step: i32, + vertical_step: i32, +) -> Option<(BlockPos, &'static Biome)> { + match world_gen { + WorldGenerator::Flat(flat) => { + // Superflat worlds use a single fixed biome everywhere, matching + // the resolution in `FlatGenerator::step_to_biomes`. + let name = flat.biome.strip_prefix("minecraft:").unwrap_or(&flat.biome); + let biome = Biome::from_name(name).unwrap_or(&Biome::PLAINS); + targets.contains(&biome.id).then_some((origin, biome)) + } + WorldGenerator::Noise(generator) => find_in_noise_world( + generator, + origin, + targets, + horizontal_radius, + horizontal_step, + vertical_step, + ), + // Plugin generators only expose biomes by generating a whole chunk, so + // there is no sampler to search against. + WorldGenerator::Custom(_) => None, + } +} + +fn find_in_noise_world( + generator: &VanillaGenerator, + origin: BlockPos, + targets: &FxHashSet, + horizontal_radius: i32, + horizontal_step: i32, + vertical_step: i32, +) -> Option<(BlockPos, &'static Biome)> { + // Vanilla intersects the request with `BiomeSource#possibleBiomes` first, + // so asking for a biome that cannot generate in this dimension returns + // immediately instead of scanning the whole search radius. + if targets.is_disjoint(&possible_biomes(&generator.dimension)) { + return None; + } + + let supplier: &dyn BiomeSupplier = if generator.dimension == Dimension::THE_END { + &TheEndBiomeSupplier + } else if generator.dimension == Dimension::THE_NETHER { + &MultiNoiseBiomeSupplier::NETHER + } else { + &MultiNoiseBiomeSupplier::OVERWORLD + }; + + let options = MultiNoiseSamplerBuilderOptions::new(1, 1, 1); + let mut sampler = MultiNoiseSampler::generate(&generator.base_router.multi_noise, &options); + + // `level.getMinY() + 1` to `level.getMaxY() + 1` in vanilla. + let min_y = i32::from(generator.settings.shape.min_y) + 1; + let max_y = + i32::from(generator.settings.shape.min_y) + i32::from(generator.settings.shape.height); + let ys = out_from_origin(origin.0.y, min_y, max_y, vertical_step); + + let check_column = |x: i32, z: i32, sampler: &mut MultiNoiseSampler| { + let biome_x = biome_coords::from_block(x); + let biome_z = biome_coords::from_block(z); + for &y in &ys { + let biome = supplier.biome(biome_x, biome_coords::from_block(y), biome_z, sampler); + if targets.contains(&biome.id) { + return Some((BlockPos::new(x, y, z), biome)); + } + } + None + }; + + let rings = horizontal_radius / horizontal_step; + for radius in 0..=rings { + if radius == 0 { + if let Some(found) = check_column(origin.0.x, origin.0.z, &mut sampler) { + return Some(found); + } + continue; + } + + // Perimeter of the square ring at Chebyshev distance `radius`. + for dx in -radius..=radius { + for dz in [-radius, radius] { + let x = origin.0.x + dx * horizontal_step; + let z = origin.0.z + dz * horizontal_step; + if let Some(found) = check_column(x, z, &mut sampler) { + return Some(found); + } + } + } + for dz in (1 - radius)..radius { + for dx in [-radius, radius] { + let x = origin.0.x + dx * horizontal_step; + let z = origin.0.z + dz * horizontal_step; + if let Some(found) = check_column(x, z, &mut sampler) { + return Some(found); + } + } + } + } + + None +} + +/// The ids of every biome the given dimension's biome source can produce. +#[must_use] +pub fn possible_biomes(dimension: &Dimension) -> FxHashSet { + let mut out = FxHashSet::default(); + if *dimension == Dimension::THE_END { + // Matches the fixed set in `TheEndBiomeSupplier`. + for biome in [ + &Biome::THE_END, + &Biome::END_HIGHLANDS, + &Biome::END_MIDLANDS, + &Biome::SMALL_END_ISLANDS, + &Biome::END_BARRENS, + ] { + out.insert(biome.id); + } + } else if *dimension == Dimension::THE_NETHER { + collect_tree_biomes(&NETHER_BIOME_SOURCE, &mut out); + } else { + collect_tree_biomes(&OVERWORLD_BIOME_SOURCE, &mut out); + } + out +} + +fn collect_tree_biomes(tree: &'static BiomeTree, out: &mut FxHashSet) { + match tree { + BiomeTree::Leaf { biome, .. } => { + out.insert(biome.id); + } + BiomeTree::Branch { nodes, .. } => { + for node in *nodes { + collect_tree_biomes(node, out); + } + } + } +} + +/// Y levels to probe, ordered outwards from `origin` (upwards first) and +/// clamped to `[min, max]`, mirroring vanilla's `Mth.outFromOrigin`. +fn out_from_origin(origin: i32, min: i32, max: i32, step: i32) -> Vec { + let start = origin.clamp(min, max); + let mut ys = vec![start]; + let mut distance = step; + loop { + let up = start + distance; + let down = start - distance; + if up > max && down < min { + break; + } + if up <= max { + ys.push(up); + } + if down >= min { + ys.push(down); + } + distance += step; + } + ys +} + +#[cfg(test)] +mod test { + use pumpkin_data::chunk::Biome; + use pumpkin_data::dimension::Dimension; + use pumpkin_util::math::position::BlockPos; + use pumpkin_util::world_seed::Seed; + use rustc_hash::FxHashSet; + + use super::super::flat::FlatGenerator; + use super::super::{GeneratorInit, VanillaGenerator, WorldGenerator}; + use super::{find_closest_biome_3d, out_from_origin}; + + fn targets(biomes: &[&Biome]) -> FxHashSet { + biomes.iter().map(|biome| biome.id).collect() + } + + #[test] + fn y_levels_spread_outwards() { + assert_eq!( + out_from_origin(64, -63, 320, 64), + vec![64, 128, 0, 192, 256, 320] + ); + // Origin outside the bounds gets clamped first. + assert_eq!(out_from_origin(-500, -63, 320, 200), vec![-63, 137]); + } + + #[test] + fn finds_known_biome() { + // Seed 13579 has a desert around block (-96, 4, 32); see + // `biome::test::biome_desert`. + let world_gen = WorldGenerator::Noise(Box::new(VanillaGenerator::new( + Seed(13579), + Dimension::OVERWORLD, + ))); + + let (pos, biome) = find_closest_biome_3d( + &world_gen, + BlockPos::new(-96, 4, 32), + &targets(&[&Biome::DESERT]), + 6400, + 32, + 64, + ) + .expect("a desert should be within range"); + assert_eq!(biome.id, Biome::DESERT.id); + assert_eq!(pos, BlockPos::new(-96, 4, 32)); + } + + #[test] + fn short_circuits_impossible_dimension_biomes() { + let world_gen = WorldGenerator::Noise(Box::new(VanillaGenerator::new( + Seed(13579), + Dimension::OVERWORLD, + ))); + + // A nether biome can never generate in the overworld; this must + // return without scanning the whole search radius. + assert!( + find_closest_biome_3d( + &world_gen, + BlockPos::new(0, 64, 0), + &targets(&[&Biome::CRIMSON_FOREST]), + 6400, + 32, + 64, + ) + .is_none() + ); + } + + #[test] + fn flat_world_has_a_single_fixed_biome() { + let world_gen = WorldGenerator::Flat(FlatGenerator::new( + Seed(0), + Dimension::OVERWORLD, + Vec::new(), + "minecraft:plains".to_string(), + )); + + let origin = BlockPos::new(17, 64, -3); + let (pos, biome) = find_closest_biome_3d( + &world_gen, + origin, + &targets(&[&Biome::PLAINS]), + 6400, + 32, + 64, + ) + .expect("the flat biome is everywhere"); + assert_eq!(biome.id, Biome::PLAINS.id); + assert_eq!(pos, origin); + + assert!( + find_closest_biome_3d( + &world_gen, + origin, + &targets(&[&Biome::DESERT]), + 6400, + 32, + 64 + ) + .is_none() + ); + } +} diff --git a/crates/pumpkin-world/src/generation/generator/mod.rs b/crates/pumpkin-world/src/generation/generator/mod.rs index 27a217e69..d967f9498 100644 --- a/crates/pumpkin-world/src/generation/generator/mod.rs +++ b/crates/pumpkin-world/src/generation/generator/mod.rs @@ -9,6 +9,7 @@ use super::noise::router::proto_noise_router::ProtoNoiseRouters; use crate::generation::proto_chunk::TerrainCache; use crate::generation::{GlobalRandomConfig, Seed}; +pub mod biome_finder; pub mod structure_finder; pub trait GeneratorInit { diff --git a/crates/pumpkin-world/src/poi/mod.rs b/crates/pumpkin-world/src/poi/mod.rs index d42a96716..5ddc71f0f 100644 --- a/crates/pumpkin-world/src/poi/mod.rs +++ b/crates/pumpkin-world/src/poi/mod.rs @@ -532,6 +532,51 @@ impl PoiStorage { results } + /// Finds the closest POI whose type matches `matches`, considering + /// entries within `radius` blocks of `center` on the x/z axes (like + /// vanilla's `PoiManager.findClosestWithType`: a chebyshev square gather + /// followed by picking the smallest 3D squared distance). + /// + /// Returns the entry's position together with its type. + pub fn find_closest_matching( + &mut self, + center: BlockPos, + radius: i32, + matches: impl Fn(&str) -> bool, + ) -> Option<(BlockPos, String)> { + let min_rx = ((center.0.x - radius) >> 4) >> 5; + let max_rx = ((center.0.x + radius) >> 4) >> 5; + let min_rz = ((center.0.z - radius) >> 4) >> 5; + let max_rz = ((center.0.z + radius) >> 4) >> 5; + + let mut best: Option<(BlockPos, String, i64)> = None; + + for rx in min_rx..=max_rx { + for rz in min_rz..=max_rz { + let region = self.get_or_load_region(rx, rz); + for entry in region.get_all() { + if (entry.x - center.0.x).abs() > radius + || (entry.z - center.0.z).abs() > radius + || !matches(&entry.poi_type) + { + continue; + } + + let dx = i64::from(entry.x - center.0.x); + let dy = i64::from(entry.y - center.0.y); + let dz = i64::from(entry.z - center.0.z); + let distance_sq = dx * dx + dy * dy + dz * dz; + + if best.as_ref().is_none_or(|(_, _, d)| distance_sq < *d) { + best = Some((entry.pos(), entry.poi_type.clone(), distance_sq)); + } + } + } + } + + best.map(|(pos, poi_type, _)| (pos, poi_type)) + } + pub fn save_all(&mut self) -> std::io::Result<()> { std::fs::create_dir_all(&self.folder)?; @@ -589,6 +634,41 @@ mod tests { assert_eq!(region.get_all().len(), 1); } + #[test] + fn poi_find_closest_matching() { + let mut storage = PoiStorage::new(std::env::temp_dir().join("pumpkin_poi_closest_test")); + + storage.add_portal(BlockPos(Vector3::new(100, 64, 100))); + storage.add_portal(BlockPos(Vector3::new(120, 64, 100))); + storage.add(BlockPos(Vector3::new(101, 64, 100)), "minecraft:home"); + + let center = BlockPos(Vector3::new(105, 64, 100)); + let (pos, poi_type) = storage + .find_closest_matching(center, 256, |t| t == POI_TYPE_NETHER_PORTAL) + .unwrap(); + assert_eq!(pos, BlockPos(Vector3::new(100, 64, 100))); + assert_eq!(poi_type, POI_TYPE_NETHER_PORTAL); + + // The overall closest one ignores the type filter mismatch above. + let (pos, poi_type) = storage + .find_closest_matching(center, 256, |_| true) + .unwrap(); + assert_eq!(pos, BlockPos(Vector3::new(101, 64, 100))); + assert_eq!(poi_type, "minecraft:home"); + + assert!( + storage + .find_closest_matching(center, 256, |t| t == "minecraft:lodestone") + .is_none() + ); + // Out of horizontal range. + assert!( + storage + .find_closest_matching(BlockPos(Vector3::new(1000, 64, 100)), 16, |_| true) + .is_none() + ); + } + #[test] fn poi_storage_mca() { let dir = std::env::temp_dir().join("pumpkin_poi_mca_test"); diff --git a/crates/pumpkin/src/command/argument_types/mod.rs b/crates/pumpkin/src/command/argument_types/mod.rs index 3eac0b3b8..cf4ec0daf 100644 --- a/crates/pumpkin/src/command/argument_types/mod.rs +++ b/crates/pumpkin/src/command/argument_types/mod.rs @@ -235,6 +235,7 @@ pub mod pool; pub mod range; pub mod resource; pub mod resource_key; +pub mod resource_or_tag; pub mod slot; pub mod structure; pub mod team; diff --git a/crates/pumpkin/src/command/argument_types/resource_or_tag.rs b/crates/pumpkin/src/command/argument_types/resource_or_tag.rs new file mode 100644 index 000000000..5b616cb5c --- /dev/null +++ b/crates/pumpkin/src/command/argument_types/resource_or_tag.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeSet; +use std::pin::Pin; + +use pumpkin_data::structures::StructureSet; +use pumpkin_data::tag::{self, RegistryKey}; +use pumpkin_data::translation; +use pumpkin_util::identifier::Identifier; +use pumpkin_util::text::TextComponent; +use pumpkin_world::poi::POI_TYPE_NETHER_PORTAL; + +use crate::command::argument_types::FromStringReader; +use crate::command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; +use crate::command::argument_types::resource_key::BIOME_REGISTRY; +use crate::command::context::command_context::CommandContext; +use crate::command::errors::command_syntax_error::CommandSyntaxError; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::string_reader::StringReader; +use crate::command::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; + +pub static STRUCTURE_REGISTRY: Identifier = Identifier::vanilla_static("worldgen/structure"); +pub static POI_REGISTRY: Identifier = Identifier::vanilla_static("point_of_interest_type"); + +pub static ERROR_UNKNOWN_RESOURCE: CommandErrorType<2> = CommandErrorType::new( + translation::java::ARGUMENT_RESOURCE_NOT_FOUND, + translation::java::ARGUMENT_RESOURCE_NOT_FOUND, +); + +pub static ERROR_UNKNOWN_TAG: CommandErrorType<2> = CommandErrorType::new( + translation::java::ARGUMENT_RESOURCE_TAG_NOT_FOUND, + translation::java::ARGUMENT_RESOURCE_TAG_NOT_FOUND, +); + +/// A parsed reference to either a single registry entry or a `#`-prefixed tag +/// of entries, as produced by [`ResourceOrTagKeyArgument`] and +/// [`ResourceOrTagArgument`]. +#[derive(Debug, Clone)] +pub enum ResourceOrTag { + Resource(Identifier), + Tag(Identifier), +} + +impl ResourceOrTag { + /// The user-facing form of this reference (vanilla's `asPrintable`): + /// `namespace:path` for entries, `#namespace:path` for tags. + #[must_use] + pub fn printable(&self) -> String { + match self { + Self::Resource(id) => id.to_string(), + Self::Tag(id) => format!("#{id}"), + } + } + + fn from_string_reader(reader: &mut StringReader) -> Result { + if reader.peek() == Some('#') { + reader.skip(); + Ok(Self::Tag(Identifier::from_reader(reader)?)) + } else { + Ok(Self::Resource(Identifier::from_reader(reader)?)) + } + } +} + +/// Registry-aware suggestions shared by both argument types: the known entry +/// ids plus, when tag data exists for the registry, its `#`-prefixed tags. +fn suggest_for_registry(registry: &Identifier, builder: SuggestionsBuilder) -> Suggestions { + let tag_names = |key: RegistryKey| { + tag::get_latest_map(key) + .into_iter() + .flat_map(|map| map.keys().map(|tag| format!("#{tag}"))) + }; + + if *registry == STRUCTURE_REGISTRY { + // The generator models vanilla's structure sets, so those are the + // locatable names. There is no structure tag data to offer. + builder + .filter_and_suggest_iter( + StructureSet::NAMES + .iter() + .map(|name| format!("minecraft:{name}")), + ) + .build() + } else if *registry == *BIOME_REGISTRY { + let biomes = pumpkin_data::biome::Biome::ALL + .iter() + .map(|biome| format!("minecraft:{}", biome.registry_id)); + builder + .filter_and_suggest_iter(biomes.chain(tag_names(RegistryKey::WorldgenBiome))) + .build() + } else if *registry == POI_REGISTRY { + // There is no generated POI type registry (yet), so offer the types + // known from tag data plus the ones the server actually creates. + let mut names: BTreeSet = tag::get_latest_map(RegistryKey::PointOfInterestType) + .into_iter() + .flat_map(|map| map.values().flat_map(|tag| tag.0.iter())) + .map(|name| format!("minecraft:{name}")) + .collect(); + names.insert(POI_TYPE_NETHER_PORTAL.to_string()); + builder + .filter_and_suggest_iter( + names + .into_iter() + .chain(tag_names(RegistryKey::PointOfInterestType)), + ) + .build() + } else { + Suggestions::empty() + } +} + +/// An argument type that parses an entry id or `#`-tag of a registry. +/// +/// The value is not validated against the registry, like vanilla's +/// `ResourceOrTagKeyArgument`; resolution (and the matching error) is left to +/// the command. +pub struct ResourceOrTagKeyArgument(pub Identifier); + +impl ArgumentType for ResourceOrTagKeyArgument { + type Item = ResourceOrTag; + + fn parse(&self, reader: &mut StringReader) -> Result { + ResourceOrTag::from_string_reader(reader) + } + + fn list_suggestions<'a>( + &'a self, + _context: &'a CommandContext, + builder: SuggestionsBuilder, + ) -> Pin + Send + 'a>> { + Box::pin(async move { suggest_for_registry(&self.0, builder) }) + } + + fn client_side_parser(&'_ self) -> JavaClientArgumentType { + JavaClientArgumentType::ResourceOrTagKey { + identifier: self.0.clone(), + } + } + + fn examples(&self) -> Vec { + examples!("foo", "foo:bar", "#foo") + } +} + +/// An argument type that parses and validates an entry id or `#`-tag of a +/// registry. +/// +/// Validation happens at parse time where registry data is available, like +/// vanilla's `ResourceOrTagArgument`: it currently covers biome ids and the +/// tags of every registry with generated tag data; ids of registries without +/// generated entry data (such as POI types) are accepted as-is. +pub struct ResourceOrTagArgument(pub Identifier); + +impl ArgumentType for ResourceOrTagArgument { + type Item = ResourceOrTag; + + fn parse(&self, reader: &mut StringReader) -> Result { + let value = ResourceOrTag::from_string_reader(reader)?; + + match &value { + ResourceOrTag::Resource(id) => { + if self.0 == *BIOME_REGISTRY + && !(id.is_vanilla() + && pumpkin_data::biome::Biome::from_name(id.path()).is_some()) + { + return Err(ERROR_UNKNOWN_RESOURCE.create_without_context( + TextComponent::text(id.to_string()), + TextComponent::text(self.0.to_string()), + )); + } + } + ResourceOrTag::Tag(id) => { + let registry_key = if self.0 == *BIOME_REGISTRY { + Some(RegistryKey::WorldgenBiome) + } else if self.0 == POI_REGISTRY { + Some(RegistryKey::PointOfInterestType) + } else { + None + }; + + if let Some(key) = registry_key + && tag::get_tag_values(key, &id.to_string()).is_none() + { + return Err(ERROR_UNKNOWN_TAG.create_without_context( + TextComponent::text(id.to_string()), + TextComponent::text(self.0.to_string()), + )); + } + } + } + + Ok(value) + } + + fn list_suggestions<'a>( + &'a self, + _context: &'a CommandContext, + builder: SuggestionsBuilder, + ) -> Pin + Send + 'a>> { + Box::pin(async move { suggest_for_registry(&self.0, builder) }) + } + + fn client_side_parser(&'_ self) -> JavaClientArgumentType { + JavaClientArgumentType::ResourceOrTag { + identifier: self.0.clone(), + } + } + + fn examples(&self) -> Vec { + examples!("foo", "foo:bar", "#foo") + } +} diff --git a/crates/pumpkin/src/command/commands/locate.rs b/crates/pumpkin/src/command/commands/locate.rs new file mode 100644 index 000000000..c3a2352d6 --- /dev/null +++ b/crates/pumpkin/src/command/commands/locate.rs @@ -0,0 +1,408 @@ +use std::borrow::Cow; + +use pumpkin_data::biome::Biome; +use pumpkin_data::structures::{StructureKeys, StructurePlacementType, StructureSet}; +use pumpkin_data::tag::{self, RegistryKey}; +use pumpkin_data::translation; +use pumpkin_util::PermissionLvl; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::click::ClickEvent; +use pumpkin_util::text::hover::HoverEvent; +use pumpkin_util::text::{TextComponent, color::NamedColor}; +use pumpkin_world::generation::generator::biome_finder::find_closest_biome_3d; +use pumpkin_world::generation::generator::structure_finder::{ + find_nearest_structure, find_nearest_structure_start, +}; +use rustc_hash::FxHashSet; + +use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; +use crate::command::argument_types::resource_key::BIOME_REGISTRY; +use crate::command::argument_types::resource_or_tag::{ + POI_REGISTRY, ResourceOrTag, ResourceOrTagArgument, ResourceOrTagKeyArgument, + STRUCTURE_REGISTRY, +}; +use crate::command::context::command_context::CommandContext; +use crate::command::errors::error_types::{CommandErrorType, LiteralCommandErrorType}; +use crate::command::node::dispatcher::CommandDispatcher; +use crate::command::node::{CommandExecutor, CommandExecutorResult}; + +const DESCRIPTION: &str = "Locates the closest structure, biome, or point of interest."; + +const PERMISSION: &str = "minecraft:command.locate"; + +const ARG_STRUCTURE: &str = "structure"; +const ARG_BIOME: &str = "biome"; +const ARG_POI: &str = "poi"; + +/// The maximum structure search radius in chunk regions, matching vanilla's +/// `findNearestMapStructure` call in `LocateCommand`. +const STRUCTURE_SEARCH_RADIUS: i32 = 100; + +/// Biome search parameters from vanilla's `LocateCommand`: a 6400 block +/// radius probed every 32 blocks horizontally and every 64 blocks vertically. +const BIOME_SEARCH_RADIUS: i32 = 6400; +const BIOME_SEARCH_HORIZONTAL_STEP: i32 = 32; +const BIOME_SEARCH_VERTICAL_STEP: i32 = 64; + +/// The POI search radius in blocks, matching vanilla's `LocateCommand`. +const POI_SEARCH_RADIUS: i32 = 256; + +static STRUCTURE_INVALID_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_LOCATE_STRUCTURE_INVALID, + translation::java::COMMANDS_LOCATE_STRUCTURE_INVALID, +); + +static STRUCTURE_NOT_FOUND_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_LOCATE_STRUCTURE_NOT_FOUND, + translation::bedrock::COMMANDS_LOCATE_STRUCTURE_FAIL_NOSTRUCTUREFOUND, +); + +static BIOME_NOT_FOUND_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_LOCATE_BIOME_NOT_FOUND, + translation::bedrock::COMMANDS_LOCATE_BIOME_FAIL, +); + +static POI_NOT_FOUND_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_LOCATE_POI_NOT_FOUND, + translation::java::COMMANDS_LOCATE_POI_NOT_FOUND, +); + +/// Raised if a blocking search task panics or is cancelled instead of +/// running to completion. Purely internal, so it has no translation. +static SEARCH_FAILED_ERROR_TYPE: LiteralCommandErrorType = + LiteralCommandErrorType::new("The locate search failed unexpectedly"); + +/// Builds the clickable green `[x, ~, z]` (or `[x, y, z]` when `absolute_y`) +/// coordinates component used by vanilla's locate feedback. +fn coordinates_text(pos: &BlockPos, absolute_y: bool) -> TextComponent { + let x = pos.0.x; + let z = pos.0.z; + let y = if absolute_y { + pos.0.y.to_string() + } else { + "~".to_string() + }; + + TextComponent::translate_cross( + translation::java::CHAT_COORDINATES, + translation::java::CHAT_COORDINATES, + [ + TextComponent::text(x.to_string()), + TextComponent::text(y.clone()), + TextComponent::text(z.to_string()), + ], + ) + .color_named(NamedColor::Green) + .click_event(ClickEvent::SuggestCommand { + command: Cow::from(format!("/tp @s {x} {y} {z}")), + }) + .hover_event(HoverEvent::show_text(TextComponent::translate_cross( + translation::java::CHAT_COORDINATES_TOOLTIP, + translation::java::CHAT_COORDINATES_TOOLTIP, + [], + ))) +} + +/// The first argument of the success messages: the searched id, with the +/// concretely found entry appended for tag searches, like vanilla's +/// `LocateCommand.showLocateResult`. +fn result_name(searched: &ResourceOrTag, found: &str) -> String { + match searched { + ResourceOrTag::Resource(_) => searched.printable(), + ResourceOrTag::Tag(_) => format!("{} ({found})", searched.printable()), + } +} + +/// Vanilla reports the horizontal block distance for structures and POIs. +fn horizontal_distance(origin: &BlockPos, target: &BlockPos) -> i32 { + let dx = f64::from(target.0.x - origin.0.x); + let dz = f64::from(target.0.z - origin.0.z); + dx.hypot(dz).floor().max(0.0) as i32 +} + +/// ... and the full 3D block distance for biomes. +fn absolute_distance(origin: &BlockPos, target: &BlockPos) -> i32 { + let dx = f64::from(target.0.x - origin.0.x); + let dy = f64::from(target.0.y - origin.0.y); + let dz = f64::from(target.0.z - origin.0.z); + (dx * dx + dy * dy + dz * dz).sqrt().floor().max(0.0) as i32 +} + +async fn send_success( + context: &CommandContext<'_>, + java_key: &'static str, + bedrock_key: &'static str, + name: String, + target: &BlockPos, + absolute_y: bool, + distance: i32, +) { + context + .source + .send_feedback( + TextComponent::translate_cross( + java_key, + bedrock_key, + [ + TextComponent::text(name), + coordinates_text(target, absolute_y), + TextComponent::text(distance.to_string()), + ], + ), + false, + ) + .await; +} + +struct LocateStructureExecutor; + +impl CommandExecutor for LocateStructureExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let searched = context.get_argument::(ARG_STRUCTURE)?; + + // The generator's placement data models vanilla's structure sets, + // so ids resolve against those. There is no structure tag data, + // hence tags cannot name any known structure (yet). + let set = if let ResourceOrTag::Resource(id) = searched + && id.is_vanilla() + { + StructureSet::get(id.path()) + } else { + None + }; + + let Some(set) = set else { + return Err(STRUCTURE_INVALID_ERROR_TYPE + .create_without_context(TextComponent::text(searched.printable()))); + }; + + let origin = BlockPos::floored_v(context.source.position); + + let world = context.source.world(); + let seed = world.level.seed.0; + let world_gen = world.level.world_gen.load_full(); + + // Scanning up to `STRUCTURE_SEARCH_RADIUS` regions of placement + // data is CPU-bound just like the biome spiral, so keep it off + // the async workers too. + let found = tokio::task::spawn_blocking(move || { + match &set.placement.placement_type { + // Strongholds come out of the pre-computed ring cache, which + // already holds positions they really occupy. + StructurePlacementType::ConcentricRings(_) => { + world_gen.global_structure_cache().and_then(|global_cache| { + find_nearest_structure( + origin, + &[&set.placement], + STRUCTURE_SEARCH_RADIUS, + seed as i64, + global_cache, + ) + }) + } + // Everything else is spread over a grid whose candidate chunks + // are only *possible* sites: the biome at a candidate can still + // reject every structure in the set. Resolving the start makes + // sure the reported position actually holds one. + StructurePlacementType::RandomSpread(_) => { + let targets: Vec = + set.structures.iter().map(|entry| entry.structure).collect(); + find_nearest_structure_start( + origin, + set, + &targets, + STRUCTURE_SEARCH_RADIUS, + &world_gen, + ) + } + } + }) + .await + .map_err(|_| SEARCH_FAILED_ERROR_TYPE.create_without_context())?; + + let Some(target) = found else { + return Err(STRUCTURE_NOT_FOUND_ERROR_TYPE + .create_without_context(TextComponent::text(searched.printable()))); + }; + + let distance = horizontal_distance(&origin, &target); + send_success( + context, + translation::java::COMMANDS_LOCATE_STRUCTURE_SUCCESS, + translation::bedrock::COMMANDS_LOCATE_STRUCTURE_SUCCESS, + searched.printable(), + &target, + false, + distance, + ) + .await; + + Ok(distance) + }) + } +} + +struct LocateBiomeExecutor; + +impl CommandExecutor for LocateBiomeExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let searched = context.get_argument::(ARG_BIOME)?.clone(); + + let targets: FxHashSet = match &searched { + ResourceOrTag::Resource(id) => Biome::from_name(id.path()) + .map(|biome| biome.id) + .into_iter() + .collect(), + ResourceOrTag::Tag(id) => { + tag::get_tag_values(RegistryKey::WorldgenBiome, &id.to_string()) + .into_iter() + .flatten() + .filter_map(|name| Biome::from_name(name)) + .map(|biome| biome.id) + .collect() + } + }; + + let not_found = || { + BIOME_NOT_FOUND_ERROR_TYPE + .create_without_context(TextComponent::text(searched.printable())) + }; + if targets.is_empty() { + return Err(not_found()); + } + + let origin = BlockPos::floored_v(context.source.position); + let world = context.source.world().clone(); + let world_gen = world.level.world_gen.load_full(); + + // The spiral scan can probe hundreds of thousands of noise + // points when the biome is rare, so keep it off the async + // workers. + let found = tokio::task::spawn_blocking(move || { + find_closest_biome_3d( + &world_gen, + origin, + &targets, + BIOME_SEARCH_RADIUS, + BIOME_SEARCH_HORIZONTAL_STEP, + BIOME_SEARCH_VERTICAL_STEP, + ) + }) + .await + .map_err(|_| SEARCH_FAILED_ERROR_TYPE.create_without_context())?; + + let Some((target, biome)) = found else { + return Err(not_found()); + }; + + let distance = absolute_distance(&origin, &target); + send_success( + context, + translation::java::COMMANDS_LOCATE_BIOME_SUCCESS, + translation::bedrock::COMMANDS_LOCATE_BIOME_SUCCESS, + result_name(&searched, &format!("minecraft:{}", biome.registry_id)), + &target, + true, + distance, + ) + .await; + + Ok(distance) + }) + } +} + +struct LocatePoiExecutor; + +impl CommandExecutor for LocatePoiExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let searched = context.get_argument::(ARG_POI)?; + + // POI entries store namespaced type ids, tag data uses bare + // vanilla names; normalize everything to `namespace:path`. + let targets: FxHashSet = match searched { + ResourceOrTag::Resource(id) => std::iter::once(id.to_string()).collect(), + ResourceOrTag::Tag(id) => { + tag::get_tag_values(RegistryKey::PointOfInterestType, &id.to_string()) + .into_iter() + .flatten() + .map(|name| { + if name.contains(':') { + (*name).to_string() + } else { + format!("minecraft:{name}") + } + }) + .collect() + } + }; + + let origin = BlockPos::floored_v(context.source.position); + let world = context.source.world().clone(); + + let found = { + let mut poi_storage = world.portal_poi.lock().await; + poi_storage.find_closest_matching(origin, POI_SEARCH_RADIUS, |poi_type| { + targets.contains(poi_type) + }) + }; + + let Some((target, poi_type)) = found else { + return Err(POI_NOT_FOUND_ERROR_TYPE + .create_without_context(TextComponent::text(searched.printable()))); + }; + + let distance = horizontal_distance(&origin, &target); + send_success( + context, + translation::java::COMMANDS_LOCATE_POI_SUCCESS, + translation::java::COMMANDS_LOCATE_POI_SUCCESS, + result_name(searched, &poi_type), + &target, + false, + distance, + ) + .await; + + Ok(distance) + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + dispatcher.register( + command("locate", DESCRIPTION) + .requires(PERMISSION) + .then( + literal("structure").then( + argument( + ARG_STRUCTURE, + ResourceOrTagKeyArgument(STRUCTURE_REGISTRY.clone()), + ) + .executes(LocateStructureExecutor), + ), + ) + .then( + literal("biome").then( + argument(ARG_BIOME, ResourceOrTagArgument(BIOME_REGISTRY.clone())) + .executes(LocateBiomeExecutor), + ), + ) + .then( + literal("poi").then( + argument(ARG_POI, ResourceOrTagArgument(POI_REGISTRY.clone())) + .executes(LocatePoiExecutor), + ), + ), + ); +} diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index a7f374c89..526020a28 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -37,6 +37,7 @@ mod item; mod kick; mod kill; mod list; +mod locate; mod loot; mod me; mod msg; @@ -188,6 +189,7 @@ pub fn default_dispatcher( place::register(&mut dispatcher, registry); random::register(&mut dispatcher, registry); list::register(&mut dispatcher, registry); + locate::register(&mut dispatcher, registry); loot::register(&mut dispatcher, registry); seed::register(&mut dispatcher, registry); saveall::register(&mut dispatcher, registry); diff --git a/crates/pumpkin/src/command/suggestion/suggestions.rs b/crates/pumpkin/src/command/suggestion/suggestions.rs index 5543c8a86..b1856ae84 100644 --- a/crates/pumpkin/src/command/suggestion/suggestions.rs +++ b/crates/pumpkin/src/command/suggestion/suggestions.rs @@ -181,7 +181,7 @@ impl SuggestionsBuilder { fn matches_substr(pattern: &str, input: &str) -> bool { let mut current_str = input; while !current_str.starts_with(pattern) { - match current_str.find(['.', '_', '/']) { + match current_str.find(['.', '_', '/', ':']) { Some(pos) => current_str = ¤t_str[(pos + 1)..], None => return false, } diff --git a/tools/pumpkin-codegen/src/structures.rs b/tools/pumpkin-codegen/src/structures.rs index ab25cf95d..326d91316 100644 --- a/tools/pumpkin-codegen/src/structures.rs +++ b/tools/pumpkin-codegen/src/structures.rs @@ -461,6 +461,7 @@ pub fn build() -> TokenStream { let mut structure_set_const_defs = TokenStream::new(); let mut structure_set_lookup_arms = TokenStream::new(); let mut all_structure_set_idents = Vec::new(); + let mut all_structure_set_names = Vec::new(); for (name, structure_set) in &structure_sets_json { let stripped_name = name.strip_prefix("minecraft:").unwrap_or(name); @@ -476,6 +477,7 @@ pub fn build() -> TokenStream { )); all_structure_set_idents.push(const_name); + all_structure_set_names.push(stripped_name.to_string()); } let structure_all_names_tokens: Vec = structure_all_names @@ -718,6 +720,11 @@ pub fn build() -> TokenStream { #(Self::#all_structure_set_idents),* ]; + /// The registry names of all structure sets, in the same order as [`Self::ALL`]. + pub const NAMES: &'static [&'static str] = &[ + #(#all_structure_set_names),* + ]; + #[must_use] pub fn get(name: &str) -> Option<&'static Self> { match name {