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