1use std::sync::LazyLock;
5
6use glam::IVec3;
7use steel_registry::biome::{BiomeRef, TemperatureModifier};
8use steel_registry::structure::{
9 LiquidSettingsData, RuinedPortalPlacementData, RuinedPortalSetupData, StructureConfigData,
10 StructureData,
11};
12use steel_utils::random::legacy_random::LegacyRandom;
13use steel_utils::random::{Random, RandomSource};
14use steel_utils::{BoundingBox, Direction, Identifier, Rotation};
15use steel_worldgen::noise::PerlinSimplexNoise;
16
17use crate::structure::{
18 GenerationStub, RuinedPortalProperties, Structure, StructureBlockIgnore,
19 StructureGenerationContext, StructureMirror, StructurePiece, StructurePiecePayload,
20 TemplateMarkerHandling, TemplatePieceData, TemplatePlacementAdjustment, TemplatePlacementClip,
21 TemplatePostProcess, TemplateProcessorList,
22};
23
24static TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
25 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(1234));
26 PerlinSimplexNoise::new(&mut random, &[0])
27});
28
29static FROZEN_TEMPERATURE_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
30 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(3456));
31 PerlinSimplexNoise::new(&mut random, &[-2, -1, 0])
32});
33
34static BIOME_INFO_NOISE: LazyLock<PerlinSimplexNoise> = LazyLock::new(|| {
35 let mut random = RandomSource::Legacy(LegacyRandom::from_seed(2345));
36 PerlinSimplexNoise::new(&mut random, &[0])
37});
38
39const PORTAL_TEMPLATES: [&str; 10] = [
40 "ruined_portal/portal_1",
41 "ruined_portal/portal_2",
42 "ruined_portal/portal_3",
43 "ruined_portal/portal_4",
44 "ruined_portal/portal_5",
45 "ruined_portal/portal_6",
46 "ruined_portal/portal_7",
47 "ruined_portal/portal_8",
48 "ruined_portal/portal_9",
49 "ruined_portal/portal_10",
50];
51
52const GIANT_PORTAL_TEMPLATES: [&str; 3] = [
53 "ruined_portal/giant_portal_1",
54 "ruined_portal/giant_portal_2",
55 "ruined_portal/giant_portal_3",
56];
57
58pub enum TerrainQuery {
60 SurfaceHeight {
62 x: i32,
64 z: i32,
66 ocean_floor: bool,
68 },
69 IsOpaque {
71 x: i32,
73 y: i32,
75 z: i32,
77 ocean_floor: bool,
79 },
80}
81
82pub enum TerrainResult {
84 Height(i32),
86 Opaque(bool),
88}
89
90pub struct PortalResult {
92 pub biome_check_pos: (i32, i32, i32),
94 pub bounding_box: BoundingBox,
96 pub template_id: Identifier,
98 pub template_size: IVec3,
100 pub rotation: Rotation,
102 pub mirror: StructureMirror,
104 pub rotation_pivot: IVec3,
106 pub vertical_placement: RuinedPortalPlacementData,
108 pub properties: RuinedPortalProperties,
110 pub can_be_cold: bool,
112}
113
114#[expect(
116 clippy::too_many_lines,
117 reason = "inlines vanilla's setup → size → rotation → mirror → placement pipeline"
118)]
119pub fn find_generation_point(
120 rng: &mut LegacyRandom,
121 chunk_x: i32,
122 chunk_z: i32,
123 setups: &[RuinedPortalSetupData],
124 min_y: i32,
125 templates: &mut dyn FnMut(&Identifier) -> Option<[i32; 3]>,
126 terrain: &mut dyn FnMut(TerrainQuery) -> TerrainResult,
127) -> Option<PortalResult> {
128 if setups.is_empty() {
129 return None;
130 }
131
132 let base_x = chunk_x * 16;
133 let base_z = chunk_z * 16;
134
135 let setup = if setups.len() > 1 {
136 let total: f32 = setups.iter().map(|s| s.weight).sum();
137 let mut pick = rng.next_f32();
138 let mut chosen_idx = setups.len() - 1;
139 for (i, s) in setups.iter().enumerate() {
140 pick -= s.weight / total;
141 if pick < 0.0 {
142 chosen_idx = i;
143 break;
144 }
145 }
146 &setups[chosen_idx]
147 } else {
148 &setups[0]
149 };
150
151 let air_pocket = if setup.air_pocket_probability <= 0.0 {
152 false
153 } else if setup.air_pocket_probability >= 1.0 {
154 true
155 } else {
156 rng.next_f32() < setup.air_pocket_probability
157 };
158
159 let template_id = if rng.next_f32() < 0.05 {
160 let index = rng.next_i32_bounded(GIANT_PORTAL_TEMPLATES.len() as i32) as usize;
161 Identifier::vanilla_static(GIANT_PORTAL_TEMPLATES[index])
162 } else {
163 let index = rng.next_i32_bounded(PORTAL_TEMPLATES.len() as i32) as usize;
164 Identifier::vanilla_static(PORTAL_TEMPLATES[index])
165 };
166 let template_size_arr = templates(&template_id)?;
167 let [sx, sy, sz] = template_size_arr;
168
169 let rotation = Rotation::get_random(rng);
170 let mirror = if rng.next_f32() < 0.5 {
171 StructureMirror::None
172 } else {
173 StructureMirror::FrontBack
174 };
175 let mirror_front_back = mirror == StructureMirror::FrontBack;
176 let pivot_x = sx / 2;
177 let pivot_z = sz / 2;
178 let bb = rotation.get_bounding_box_full(
179 IVec3::new(base_x, 0, base_z),
180 IVec3::new(sx, sy, sz),
181 IVec3::new(pivot_x, 0, pivot_z),
182 mirror_front_back,
183 );
184
185 let bb_center_x = bb.min_x() + (bb.max_x() - bb.min_x() + 1) / 2;
186 let bb_center_z = bb.min_z() + (bb.max_z() - bb.min_z() + 1) / 2;
187 let ocean_floor = matches!(setup.placement, RuinedPortalPlacementData::OnOceanFloor);
188 let surface_y = match terrain(TerrainQuery::SurfaceHeight {
189 x: bb_center_x,
190 z: bb_center_z,
191 ocean_floor,
192 }) {
193 TerrainResult::Height(h) => h,
194 TerrainResult::Opaque(_) => unreachable!(),
195 } - 1;
196
197 let min_y_threshold = min_y + 15;
198 let new_y = match setup.placement {
199 RuinedPortalPlacementData::OnLandSurface | RuinedPortalPlacementData::OnOceanFloor => {
200 surface_y
201 }
202 RuinedPortalPlacementData::Underground => {
203 let max_y = surface_y - sy;
204 if min_y_threshold < max_y {
205 rng.next_i32_between(min_y_threshold, max_y)
206 } else {
207 max_y
208 }
209 }
210 RuinedPortalPlacementData::InMountain => {
211 let max_y = surface_y - sy;
212 if 70 < max_y {
213 rng.next_i32_between(70, max_y)
214 } else {
215 max_y
216 }
217 }
218 RuinedPortalPlacementData::PartlyBuried => surface_y - sy + rng.next_i32_between(2, 8),
219 RuinedPortalPlacementData::InNether => {
220 if air_pocket {
221 rng.next_i32_between(32, 100)
222 } else if rng.next_f32() < 0.5 {
223 rng.next_i32_between(27, 29)
224 } else {
225 rng.next_i32_between(29, 100)
226 }
227 }
228 };
229
230 let corners = [
231 (bb.min_x(), bb.min_z()),
232 (bb.max_x(), bb.min_z()),
233 (bb.min_x(), bb.max_z()),
234 (bb.max_x(), bb.max_z()),
235 ];
236 let mut projected_y = new_y;
237 'scan: while projected_y > min_y_threshold {
238 let mut solid_count = 0;
239 for &(cx, cz) in &corners {
240 if matches!(
241 terrain(TerrainQuery::IsOpaque {
242 x: cx,
243 y: projected_y,
244 z: cz,
245 ocean_floor,
246 }),
247 TerrainResult::Opaque(true)
248 ) {
249 solid_count += 1;
250 if solid_count == 3 {
251 break 'scan;
252 }
253 }
254 }
255 projected_y -= 1;
256 }
257
258 Some(PortalResult {
259 biome_check_pos: (base_x, projected_y, base_z),
260 bounding_box: rotation.get_bounding_box_full(
261 IVec3::new(base_x, projected_y, base_z),
262 IVec3::new(sx, sy, sz),
263 IVec3::new(pivot_x, 0, pivot_z),
264 mirror_front_back,
265 ),
266 template_id,
267 template_size: IVec3::new(sx, sy, sz),
268 rotation,
269 mirror,
270 rotation_pivot: IVec3::new(pivot_x, 0, pivot_z),
271 vertical_placement: setup.placement,
272 properties: RuinedPortalProperties {
273 cold: false,
274 mossiness: setup.mossiness,
275 air_pocket,
276 overgrown: setup.overgrown,
277 vines: setup.vines,
278 replace_with_blackstone: setup.replace_with_blackstone,
279 },
280 can_be_cold: setup.can_be_cold,
281 })
282}
283
284#[expect(
285 clippy::too_many_arguments,
286 reason = "ruined portal piece construction mirrors vanilla template-piece fields"
287)]
288fn make_ruined_portal_piece(
289 template_id: Identifier,
290 position: IVec3,
291 rotation: Rotation,
292 mirror: StructureMirror,
293 rotation_pivot: IVec3,
294 size: IVec3,
295 vertical_placement: RuinedPortalPlacementData,
296 properties: RuinedPortalProperties,
297) -> StructurePiece {
298 let mirror_front_back = mirror == StructureMirror::FrontBack;
299 let bounding_box =
300 rotation.get_bounding_box_full(position, size, rotation_pivot, mirror_front_back);
301 StructurePiece {
302 piece_type: Identifier::new_static("minecraft", "rupo"),
303 bounding_box,
304 gen_depth: 0,
305 orientation: Some(Direction::North),
306 payload: StructurePiecePayload::Template(TemplatePieceData {
307 template_id,
308 template_position: position,
309 rotation,
310 mirror,
311 rotation_pivot,
312 block_ignore: if properties.air_pocket {
313 StructureBlockIgnore::StructureBlock
314 } else {
315 StructureBlockIgnore::StructureAndAir
316 },
317 late_block_ignore: StructureBlockIgnore::None,
318 processors: TemplateProcessorList::RuinedPortal {
319 vertical_placement,
320 properties,
321 },
322 liquid_settings: LiquidSettingsData::ApplyWaterlogging,
323 marker_handling: TemplateMarkerHandling::Ignore,
324 placement_adjustment: TemplatePlacementAdjustment::None,
325 placement_clip:
326 TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate,
327 post_process: TemplatePostProcess::RuinedPortal,
328 }),
329 ground_level_delta: 0,
330 junctions: Vec::new(),
331 projection: None,
332 }
333}
334
335fn cold_enough_to_snow(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> bool {
336 biome_temperature(biome, sea_level, pos) < 0.15
337}
338
339fn biome_temperature(biome: BiomeRef, sea_level: i32, pos: (i32, i32, i32)) -> f32 {
340 let modified_temperature = match biome.temperature_modifier {
341 TemperatureModifier::None => biome.temperature,
342 TemperatureModifier::Frozen => {
343 let large = FROZEN_TEMPERATURE_NOISE
344 .get_value(f64::from(pos.0) * 0.05, f64::from(pos.2) * 0.05)
345 * 7.0;
346 let edge = BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.2, f64::from(pos.2) * 0.2);
347 if large + edge < 0.3 {
348 let small =
349 BIOME_INFO_NOISE.get_value(f64::from(pos.0) * 0.09, f64::from(pos.2) * 0.09);
350 if small < 0.8 { 0.2 } else { biome.temperature }
351 } else {
352 biome.temperature
353 }
354 }
355 };
356
357 let snow_level = sea_level + 17;
358 if pos.1 <= snow_level {
359 return modified_temperature;
360 }
361
362 let value =
363 TEMPERATURE_NOISE.get_value(f64::from(pos.0) / 8.0, f64::from(pos.2) / 8.0) as f32 * 8.0;
364 modified_temperature - (value + pos.1 as f32 - snow_level as f32) * 0.05 / 40.0
365}
366
367pub struct RuinedPortalStructure;
372
373impl Structure for RuinedPortalStructure {
374 fn find_generation_point(
375 &self,
376 ctx: &mut dyn StructureGenerationContext,
377 structure: &StructureData,
378 rng: &mut LegacyRandom,
379 ) -> Option<GenerationStub> {
380 let mut template_choices = Vec::with_capacity(
381 PORTAL_TEMPLATES
382 .len()
383 .saturating_add(GIANT_PORTAL_TEMPLATES.len()),
384 );
385 for path in PORTAL_TEMPLATES.iter().chain(GIANT_PORTAL_TEMPLATES.iter()) {
386 let id = Identifier::vanilla_static(path);
387 let size = ctx.templates().get(&id)?.size;
388 template_choices.push((id, size));
389 }
390
391 let mut terrain = |q: TerrainQuery| -> TerrainResult {
392 match q {
393 TerrainQuery::SurfaceHeight { x, z, ocean_floor } => {
394 TerrainResult::Height(ctx.terrain_surface_height(x, z, ocean_floor))
395 }
396 TerrainQuery::IsOpaque {
397 x,
398 y,
399 z,
400 ocean_floor,
401 } => TerrainResult::Opaque(ctx.terrain_is_opaque(x, y, z, ocean_floor)),
402 }
403 };
404
405 let StructureConfigData::RuinedPortal { setups } = &structure.config else {
406 return None;
407 };
408 if setups.is_empty() {
409 return None;
410 }
411
412 let result = find_generation_point(
413 rng,
414 ctx.chunk_x(),
415 ctx.chunk_z(),
416 setups,
417 ctx.min_y(),
418 &mut |id| {
419 template_choices
420 .iter()
421 .find(|(template_id, _)| template_id == id)
422 .map(|(_, size)| *size)
423 },
424 &mut terrain,
425 )?;
426
427 let (bx, by, bz) = result.biome_check_pos;
428 let biome = ctx.biome_at(bx, by, bz);
429 if !structure.allowed_biomes.contains(&biome.key) {
430 return None;
431 }
432
433 let mut properties = result.properties;
434 if result.can_be_cold {
435 properties.cold = cold_enough_to_snow(biome, ctx.sea_level(), result.biome_check_pos);
436 }
437
438 Some(GenerationStub {
439 position: result.biome_check_pos,
440 pieces: vec![make_ruined_portal_piece(
441 result.template_id,
442 IVec3::new(
443 result.biome_check_pos.0,
444 result.biome_check_pos.1,
445 result.biome_check_pos.2,
446 ),
447 result.rotation,
448 result.mirror,
449 result.rotation_pivot,
450 result.template_size,
451 result.vertical_placement,
452 properties,
453 )],
454 })
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn ruined_portal_piece_uses_template_payload_with_processors_and_postprocess() {
464 let position = IVec3::new(64, 72, -32);
465 let size = IVec3::new(11, 17, 16);
466 let properties = RuinedPortalProperties {
467 cold: true,
468 mossiness: 0.8,
469 air_pocket: false,
470 overgrown: true,
471 vines: true,
472 replace_with_blackstone: false,
473 };
474
475 let piece = make_ruined_portal_piece(
476 Identifier::vanilla_static("ruined_portal/giant_portal_1"),
477 position,
478 Rotation::Clockwise90,
479 StructureMirror::FrontBack,
480 IVec3::new(size.x / 2, 0, size.z / 2),
481 size,
482 RuinedPortalPlacementData::OnOceanFloor,
483 properties,
484 );
485
486 assert_eq!(
487 piece.piece_type,
488 Identifier::new_static("minecraft", "rupo")
489 );
490 assert_eq!(piece.gen_depth, 0);
491 assert_eq!(piece.orientation, Some(Direction::North));
492 assert_eq!(
493 piece.bounding_box,
494 Rotation::Clockwise90.get_bounding_box_full(
495 position,
496 size,
497 IVec3::new(size.x / 2, 0, size.z / 2),
498 true,
499 ),
500 );
501
502 let StructurePiecePayload::Template(data) = piece.payload else {
503 panic!("ruined portal piece should be template-backed");
504 };
505 assert_eq!(
506 data.template_id,
507 Identifier::vanilla_static("ruined_portal/giant_portal_1")
508 );
509 assert_eq!(data.template_position, position);
510 assert_eq!(data.rotation, Rotation::Clockwise90);
511 assert_eq!(data.mirror, StructureMirror::FrontBack);
512 assert_eq!(data.rotation_pivot, IVec3::new(size.x / 2, 0, size.z / 2));
513 assert_eq!(data.block_ignore, StructureBlockIgnore::StructureAndAir);
514 assert_eq!(data.late_block_ignore, StructureBlockIgnore::None);
515 assert_eq!(
516 data.processors,
517 TemplateProcessorList::RuinedPortal {
518 vertical_placement: RuinedPortalPlacementData::OnOceanFloor,
519 properties,
520 }
521 );
522 assert_eq!(data.liquid_settings, LiquidSettingsData::ApplyWaterlogging);
523 assert_eq!(data.marker_handling, TemplateMarkerHandling::Ignore);
524 assert_eq!(data.placement_adjustment, TemplatePlacementAdjustment::None);
525 assert_eq!(
526 data.placement_clip,
527 TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate
528 );
529 assert_eq!(data.post_process, TemplatePostProcess::RuinedPortal);
530 }
531}