1use glam::IVec3;
5use rayon::prelude::*;
6use steel_utils::BlockPos;
7use steel_utils::ChunkPos;
8use steel_utils::Identifier;
9use steel_utils::random::Random;
10use steel_utils::random::legacy_random::LegacyRandom;
11
12const PARALLEL_RING_SNAP_THRESHOLD: usize = 8;
13
14#[derive(Debug, Clone, Copy)]
16pub enum SpreadType {
17 Linear,
19 Triangular,
21}
22
23impl SpreadType {
24 pub fn evaluate(self, rng: &mut LegacyRandom, limit: i32) -> i32 {
26 match self {
27 Self::Linear => rng.next_i32_bounded(limit),
28 #[expect(
30 clippy::manual_midpoint,
31 reason = "midpoint would change overflow vs vanilla"
32 )]
33 Self::Triangular => (rng.next_i32_bounded(limit) + rng.next_i32_bounded(limit)) / 2,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, Default)]
41pub enum FrequencyReductionMethod {
42 #[default]
44 Default,
45 LegacyType1,
47 LegacyType2,
49 LegacyType3,
51}
52
53impl FrequencyReductionMethod {
54 #[must_use]
57 pub fn should_generate(
58 self,
59 seed: i64,
60 salt: i32,
61 source_x: i32,
62 source_z: i32,
63 probability: f32,
64 ) -> bool {
65 let mut rng = LegacyRandom::from_seed(0);
66 match self {
67 Self::Default => {
68 rng.set_large_feature_with_salt(seed, salt, source_x, source_z);
69 rng.next_f32() < probability
70 }
71 Self::LegacyType1 => {
72 let cx = source_x >> 4;
73 let cz = source_z >> 4;
74 rng.set_seed(i64::from(cx ^ (cz << 4)) ^ seed);
75 rng.next_i32();
76 #[expect(
77 clippy::cast_possible_truncation,
78 reason = "vanilla: truncates reciprocal probability to i32 bound"
79 )]
80 let bound = (1.0_f32 / probability) as i32;
81 rng.next_i32_bounded(bound) == 0
82 }
83 Self::LegacyType2 => {
84 rng.set_large_feature_with_salt(seed, source_x, source_z, 10_387_320);
85 rng.next_f32() < probability
86 }
87 Self::LegacyType3 => {
88 rng.set_large_feature_seed(seed, source_x, source_z);
89 rng.next_f64() < f64::from(probability)
90 }
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct ExclusionZone {
98 pub other_set: Identifier,
100 pub chunk_count: i32,
102}
103
104fn java_round(v: f64) -> i32 {
107 (v + 0.5).floor() as i32
108}
109
110#[must_use]
114pub fn generate_ring_positions<F>(
115 seed: i64,
116 distance: i32,
117 spread: i32,
118 count: i32,
119 snap_biome: Option<&F>,
120 thread_pool: &rayon::ThreadPool,
121) -> Vec<ChunkPos>
122where
123 F: Fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)> + Sync,
124{
125 use std::f64::consts::TAU;
126
127 struct RingCandidate {
128 initial_x: i32,
129 initial_z: i32,
130 forked: LegacyRandom,
131 }
132
133 fn map_candidate<F>(candidate: RingCandidate, snap: &F) -> ChunkPos
134 where
135 F: Fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)> + Sync,
136 {
137 let RingCandidate {
138 initial_x,
139 initial_z,
140 mut forked,
141 } = candidate;
142
143 if let Some((sx, sz)) = snap(initial_x * 16 + 8, initial_z * 16 + 8, &mut forked) {
145 ChunkPos::new(sx >> 4, sz >> 4)
146 } else {
147 ChunkPos::new(initial_x, initial_z)
148 }
149 }
150
151 if count == 0 {
152 return vec![];
153 }
154
155 let mut rng = LegacyRandom::from_seed(seed as u64);
156 let mut candidates = Vec::with_capacity(count as usize);
157 let mut angle = rng.next_f64() * TAU;
158 let mut spread = spread;
159 let mut position_in_circle = 0;
160 let mut circle = 0;
161
162 let distance_f = f64::from(distance);
163
164 for i in 0..count {
165 let dist = (4.0 + 6.0 * f64::from(circle)) * distance_f
166 + (rng.next_f64() - 0.5) * distance_f * 2.5;
167 let initial_x = java_round(angle.cos() * dist);
168 let initial_z = java_round(angle.sin() * dist);
169
170 candidates.push(RingCandidate {
173 initial_x,
174 initial_z,
175 forked: rng.fork(),
176 });
177
178 angle += TAU / f64::from(spread);
179 position_in_circle += 1;
180 if position_in_circle == spread {
181 circle += 1;
182 position_in_circle = 0;
183 spread += 2 * spread / (circle + 1);
184 spread = spread.min(count - i);
185 angle += rng.next_f64() * TAU;
186 }
187 }
188
189 match snap_biome {
190 Some(snap) if candidates.len() >= PARALLEL_RING_SNAP_THRESHOLD => {
191 thread_pool.install(|| {
192 candidates
193 .into_par_iter()
194 .map(|candidate| map_candidate(candidate, snap))
195 .collect()
196 })
197 }
198 Some(snap) => candidates
199 .into_iter()
200 .map(|candidate| map_candidate(candidate, snap))
201 .collect(),
202 None => candidates
203 .into_iter()
204 .map(|candidate| ChunkPos::new(candidate.initial_x, candidate.initial_z))
205 .collect(),
206 }
207}
208
209#[derive(Debug, Clone)]
211pub enum PlacementKind {
212 RandomSpread {
214 spacing: i32,
216 separation: i32,
218 spread_type: SpreadType,
220 },
221 ConcentricRings {
223 distance: i32,
225 spread: i32,
227 count: i32,
229 preferred_biomes: Vec<Identifier>,
231 },
232}
233
234#[derive(Debug, Clone)]
236pub struct StructurePlacement {
237 pub salt: i32,
239 pub frequency: f32,
241 pub frequency_reduction_method: FrequencyReductionMethod,
243 pub exclusion_zone: Option<ExclusionZone>,
245 pub locate_offset: IVec3,
247 pub kind: PlacementKind,
249}
250
251impl StructurePlacement {
252 #[must_use]
254 pub const fn locate_pos(&self, chunk_pos: ChunkPos) -> BlockPos {
255 BlockPos::new(
256 chunk_pos.0.x * 16 + self.locate_offset.x,
257 self.locate_offset.y,
258 chunk_pos.0.y * 16 + self.locate_offset.z,
259 )
260 }
261
262 #[must_use]
265 pub fn is_structure_chunk(
266 &self,
267 seed: i64,
268 source_x: i32,
269 source_z: i32,
270 ring_positions: Option<&[ChunkPos]>,
271 ) -> bool {
272 if !self.is_placement_chunk(seed, source_x, source_z, ring_positions) {
273 return false;
274 }
275 if self.frequency < 1.0
276 && !self.frequency_reduction_method.should_generate(
277 seed,
278 self.salt,
279 source_x,
280 source_z,
281 self.frequency,
282 )
283 {
284 return false;
285 }
286 true
287 }
288
289 fn is_placement_chunk(
290 &self,
291 seed: i64,
292 source_x: i32,
293 source_z: i32,
294 ring_positions: Option<&[ChunkPos]>,
295 ) -> bool {
296 match &self.kind {
297 PlacementKind::RandomSpread {
298 spacing,
299 separation,
300 spread_type,
301 } => {
302 let potential = Self::get_potential_structure_chunk(
303 seed,
304 self.salt,
305 source_x,
306 source_z,
307 *spacing,
308 *separation,
309 *spread_type,
310 );
311 potential.0.x == source_x && potential.0.y == source_z
312 }
313 PlacementKind::ConcentricRings { .. } => ring_positions
314 .is_some_and(|positions| positions.contains(&ChunkPos::new(source_x, source_z))),
315 }
316 }
317
318 #[must_use]
320 pub fn get_potential_structure_chunk(
321 seed: i64,
322 salt: i32,
323 source_x: i32,
324 source_z: i32,
325 spacing: i32,
326 separation: i32,
327 spread_type: SpreadType,
328 ) -> ChunkPos {
329 let grid_x = source_x.div_euclid(spacing);
330 let grid_z = source_z.div_euclid(spacing);
331
332 let mut rng = LegacyRandom::from_seed(0);
333 rng.set_large_feature_with_salt(seed, grid_x, grid_z, salt);
334
335 let limit = spacing - separation;
336 let spread_x = spread_type.evaluate(&mut rng, limit);
337 let spread_z = spread_type.evaluate(&mut rng, limit);
338 ChunkPos::new(grid_x * spacing + spread_x, grid_z * spacing + spread_z)
339 }
340}
341
342#[derive(Debug, Clone)]
344pub struct StructureSelectionEntry {
345 pub structure: Identifier,
347 pub weight: i32,
349}
350
351#[derive(Debug, Clone)]
353pub struct StructureSet {
354 pub structures: Vec<StructureSelectionEntry>,
356 pub placement: StructurePlacement,
358}
359
360use steel_registry::structure_set::{
361 FrequencyMethodData, PlacementData, SpreadTypeData, StructureSetData,
362};
363
364impl From<SpreadTypeData> for SpreadType {
365 fn from(data: SpreadTypeData) -> Self {
366 match data {
367 SpreadTypeData::Linear => Self::Linear,
368 SpreadTypeData::Triangular => Self::Triangular,
369 }
370 }
371}
372
373impl From<FrequencyMethodData> for FrequencyReductionMethod {
374 fn from(data: FrequencyMethodData) -> Self {
375 match data {
376 FrequencyMethodData::Default => Self::Default,
377 FrequencyMethodData::LegacyType1 => Self::LegacyType1,
378 FrequencyMethodData::LegacyType2 => Self::LegacyType2,
379 FrequencyMethodData::LegacyType3 => Self::LegacyType3,
380 }
381 }
382}
383
384fn convert_structure_set(data: StructureSetData) -> (Identifier, StructureSet) {
385 let structures = data
386 .structures
387 .into_iter()
388 .map(|e| StructureSelectionEntry {
389 structure: e.structure,
390 weight: e.weight,
391 })
392 .collect();
393
394 let placement = match data.placement {
395 PlacementData::RandomSpread {
396 spacing,
397 separation,
398 spread_type,
399 salt,
400 frequency,
401 frequency_reduction_method,
402 exclusion_zone,
403 locate_offset,
404 } => StructurePlacement {
405 salt,
406 frequency,
407 frequency_reduction_method: frequency_reduction_method.into(),
408 exclusion_zone: exclusion_zone.map(|ez| ExclusionZone {
409 other_set: ez.other_set,
410 chunk_count: ez.chunk_count,
411 }),
412 locate_offset,
413 kind: PlacementKind::RandomSpread {
414 spacing,
415 separation,
416 spread_type: spread_type.into(),
417 },
418 },
419 PlacementData::ConcentricRings {
420 distance,
421 spread,
422 count,
423 preferred_biomes,
424 salt,
425 frequency,
426 frequency_reduction_method,
427 locate_offset,
428 } => StructurePlacement {
429 salt,
430 frequency,
431 frequency_reduction_method: frequency_reduction_method.into(),
432 exclusion_zone: None,
433 locate_offset,
434 kind: PlacementKind::ConcentricRings {
435 distance,
436 spread,
437 count,
438 preferred_biomes,
439 },
440 },
441 };
442
443 (
444 data.key,
445 StructureSet {
446 structures,
447 placement,
448 },
449 )
450}
451
452#[must_use]
454pub fn load_vanilla_structure_sets() -> Vec<(Identifier, StructureSet)> {
455 use steel_registry::vanilla_structure_sets;
456 vanilla_structure_sets::vanilla_structure_sets()
457 .into_iter()
458 .map(convert_structure_set)
459 .collect()
460}
461
462#[cfg(test)]
463mod tests {
464 use steel_utils::random::Random;
465
466 use super::*;
467
468 #[test]
469 fn test_spread_type_linear() {
470 let mut rng = LegacyRandom::from_seed(42);
471 let result = SpreadType::Linear.evaluate(&mut rng, 26);
472 assert!((0..26).contains(&result));
474 }
475
476 #[test]
477 fn test_spread_type_triangular() {
478 let mut rng = LegacyRandom::from_seed(42);
479 let result = SpreadType::Triangular.evaluate(&mut rng, 26);
480 assert!((0..26).contains(&result));
481 }
482
483 #[test]
484 fn test_village_placement_seed_0() {
485 let placement = StructurePlacement {
487 salt: 10_387_312,
488 frequency: 1.0,
489 frequency_reduction_method: FrequencyReductionMethod::Default,
490 exclusion_zone: None,
491 locate_offset: IVec3::ZERO,
492 kind: PlacementKind::RandomSpread {
493 spacing: 34,
494 separation: 8,
495 spread_type: SpreadType::Linear,
496 },
497 };
498
499 let potential = StructurePlacement::get_potential_structure_chunk(
503 0,
504 10_387_312,
505 0,
506 0,
507 34,
508 8,
509 SpreadType::Linear,
510 );
511
512 let mut rng = LegacyRandom::from_seed(0);
514 rng.set_large_feature_with_salt(0, 0, 0, 10_387_312);
515 let spread_x = rng.next_i32_bounded(26); let spread_z = rng.next_i32_bounded(26);
517 assert_eq!(potential, ChunkPos::new(spread_x, spread_z));
518
519 assert!(placement.is_structure_chunk(0, potential.0.x, potential.0.y, None));
522 if potential != ChunkPos::new(0, 0) {
525 assert!(!placement.is_structure_chunk(0, 0, 0, None));
526 }
527 }
528
529 #[test]
530 fn test_negative_chunk_coords() {
531 let potential_pos = StructurePlacement::get_potential_structure_chunk(
533 0,
534 10_387_312,
535 -1,
536 -1,
537 34,
538 8,
539 SpreadType::Linear,
540 );
541
542 assert!(potential_pos.0.x >= -34 && potential_pos.0.x < -34 + 26);
545 assert!(potential_pos.0.y >= -34 && potential_pos.0.y < -34 + 26);
546 }
547
548 #[test]
549 fn test_frequency_reduction_skips_at_1() {
550 let placement = StructurePlacement {
551 salt: 12345,
552 frequency: 1.0,
553 frequency_reduction_method: FrequencyReductionMethod::Default,
554 exclusion_zone: None,
555 locate_offset: IVec3::ZERO,
556 kind: PlacementKind::RandomSpread {
557 spacing: 32,
558 separation: 8,
559 spread_type: SpreadType::Linear,
560 },
561 };
562
563 let potential = StructurePlacement::get_potential_structure_chunk(
565 0,
566 12345,
567 0,
568 0,
569 32,
570 8,
571 SpreadType::Linear,
572 );
573 assert!(placement.is_structure_chunk(0, potential.0.x, potential.0.y, None));
575 }
576
577 #[test]
578 fn test_load_vanilla_structure_sets() {
579 let sets = load_vanilla_structure_sets();
580 assert_eq!(sets.len(), 20);
581
582 let (key, villages) = sets
584 .iter()
585 .find(|(k, _)| &*k.path == "villages")
586 .expect("villages structure set must be present");
587 assert_eq!(&*key.namespace, "minecraft");
588 assert_eq!(villages.structures.len(), 5);
589 if let PlacementKind::RandomSpread {
590 spacing,
591 separation,
592 spread_type: _,
593 } = &villages.placement.kind
594 {
595 assert_eq!(*spacing, 34);
596 assert_eq!(*separation, 8);
597 } else {
598 panic!("Expected RandomSpread for villages");
599 }
600 assert_eq!(villages.placement.salt, 10_387_312);
601
602 let (_, buried_treasures) = sets
604 .iter()
605 .find(|(k, _)| &*k.path == "buried_treasures")
606 .expect("buried_treasures structure set must be present");
607 assert_eq!(
608 buried_treasures.placement.locate_offset,
609 IVec3::new(9, 0, 9)
610 );
611
612 let (_, strongholds) = sets
614 .iter()
615 .find(|(k, _)| &*k.path == "strongholds")
616 .expect("strongholds structure set must be present");
617 assert!(matches!(
618 strongholds.placement.kind,
619 PlacementKind::ConcentricRings { .. }
620 ));
621
622 let (_, outposts) = sets
624 .iter()
625 .find(|(k, _)| &*k.path == "pillager_outposts")
626 .expect("pillager_outposts structure set must be present");
627 let ez = outposts
628 .placement
629 .exclusion_zone
630 .as_ref()
631 .expect("pillager_outposts has an exclusion zone");
632 assert_eq!(&*ez.other_set.path, "villages");
633 assert_eq!(ez.chunk_count, 10);
634 }
635
636 #[test]
637 fn test_concentric_rings_with_positions() {
638 let placement = StructurePlacement {
639 salt: 0,
640 frequency: 1.0,
641 frequency_reduction_method: FrequencyReductionMethod::Default,
642 exclusion_zone: None,
643 locate_offset: IVec3::ZERO,
644 kind: PlacementKind::ConcentricRings {
645 distance: 32,
646 spread: 3,
647 count: 128,
648 preferred_biomes: vec![],
649 },
650 };
651
652 let positions = vec![ChunkPos::new(10, 20), ChunkPos::new(-5, 15)];
653
654 assert!(placement.is_structure_chunk(0, 10, 20, Some(&positions)));
655 assert!(placement.is_structure_chunk(0, -5, 15, Some(&positions)));
656 assert!(!placement.is_structure_chunk(0, 0, 0, Some(&positions)));
657
658 assert!(!placement.is_structure_chunk(0, 10, 20, None));
660 }
661
662 #[test]
663 fn test_generate_ring_positions_strongholds() {
664 let thread_pool = rayon::ThreadPoolBuilder::default()
666 .build()
667 .expect("Couldn't create a new thread pool.");
668 let positions = generate_ring_positions::<
669 fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
670 >(0, 32, 3, 128, None, &thread_pool);
671 assert_eq!(positions.len(), 128);
672
673 let first = positions[0];
676 let dist = (f64::from(first.0.x).powi(2) + f64::from(first.0.y).powi(2)).sqrt();
677 assert!(
678 dist > 80.0 && dist < 200.0,
679 "First stronghold at distance {dist}, expected ~128"
680 );
681
682 let mut unique = positions.clone();
684 unique.sort_by_key(|p| (p.0.x, p.0.y));
685 unique.dedup_by_key(|p| (p.0.x, p.0.y));
686 assert_eq!(
687 unique.len(),
688 positions.len(),
689 "Ring positions should be unique"
690 );
691
692 let positions2 = generate_ring_positions::<
694 fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
695 >(0, 32, 3, 128, None, &thread_pool);
696 assert_eq!(positions, positions2);
697 }
698
699 #[test]
700 fn test_generate_ring_positions_zero_count() {
701 let thread_pool = rayon::ThreadPoolBuilder::default()
702 .build()
703 .expect("Couldn't create a new thread pool.");
704 let positions = generate_ring_positions::<
705 fn(i32, i32, &mut LegacyRandom) -> Option<(i32, i32)>,
706 >(0, 32, 3, 0, None, &thread_pool);
707 assert!(positions.is_empty());
708 }
709
710 #[test]
711 fn test_java_round() {
712 assert_eq!(java_round(0.5), 1);
713 assert_eq!(java_round(-0.5), 0);
714 assert_eq!(java_round(1.5), 2);
715 assert_eq!(java_round(-1.5), -1);
716 assert_eq!(java_round(2.3), 2);
717 assert_eq!(java_round(-2.3), -2);
718 }
719}