diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index d4dac4551..7a4884cd8 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -321,65 +321,24 @@ pub fn derive_voxel_column( MorphologyFamily::AlluvialPlain => { generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) } - - // ── T-1029 stubs: NOT YET IMPLEMENTED ──────────────────────────── - // Each stub falls back to AlluvialPlain geometry so production does - // not crash on them. T-1029 replaces these one family at a time. - // The comment "T-1029 — NOT YET IMPLEMENTED" is the search target. - - // T-1029 — NOT YET IMPLEMENTED: LavaField - // LavaField geometry: lava sheets + shield slopes + tubes. - // For now: AlluvialPlain fallback with Lava terrain material. - MorphologyFamily::LavaField => { - let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); - col.terrain = TerrainMaterial::Lava; - col.vegetation = Vegetation::Barren; // Lava surfaces are barren. - col - } - - // T-1029 — NOT YET IMPLEMENTED: FjordWall - // FjordWall geometry: U-valley walls, deep water inlets, ribbon geometry. + MorphologyFamily::LavaField => generate_lava_field(region, voxel_pos, sub_chunk_seed), MorphologyFamily::FjordWall => { - generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) + generate_fjord_wall(region, chunk, voxel_pos, sub_chunk_seed) } - - // T-1029 — NOT YET IMPLEMENTED: CliffCoast - // CliffCoast geometry: vertical Rock faces at water edge. MorphologyFamily::CliffCoast => { - let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); - col.terrain = TerrainMaterial::Rock; - col + generate_cliff_coast(region, chunk, voxel_pos, sub_chunk_seed) } - - // T-1029 — NOT YET IMPLEMENTED: BraidedDelta - // BraidedDelta geometry: Gravel substrate, braided channels, bridges as forced nodes. MorphologyFamily::BraidedDelta => { - let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); - col.terrain = TerrainMaterial::Gravel; - col + generate_braided_delta(region, chunk, voxel_pos, sub_chunk_seed) } - - // T-1029 — NOT YET IMPLEMENTED: DuneStrand - // DuneStrand geometry: Sand substrate, ≤32° angle of repose, wind-aligned. MorphologyFamily::DuneStrand => { - let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); - col.terrain = TerrainMaterial::Sand; - col.vegetation = Vegetation::Barren; // Dunes are mostly barren. - col + generate_dune_strand(region, chunk, voxel_pos, sub_chunk_seed) } - - // T-1029 — NOT YET IMPLEMENTED: IncisedGorge - // IncisedGorge geometry: Rock walls, narrow floor (2–8 m), high elevation steps. MorphologyFamily::IncisedGorge => { - let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); - col.terrain = TerrainMaterial::Rock; - col + generate_incised_gorge(region, chunk, voxel_pos, sub_chunk_seed) } - - // T-1029 — NOT YET IMPLEMENTED: MeanderReach - // MeanderReach geometry: Soil, sinuous channel, levees, D-239 §9 ElevationDelta. MorphologyFamily::MeanderReach => { - generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) + generate_meander_reach(region, chunk, voxel_pos, sub_chunk_seed) } } } @@ -480,6 +439,810 @@ fn generate_alluvial_plain( } } +// --------------------------------------------------------------------------- +// LavaField generator (T-1029, D-239 §5 / §8 Lava law) +// --------------------------------------------------------------------------- + +/// LavaField voxel generator. +/// +/// Shield-slope topology with lava sheets and tube voids: +/// - `Lava` terrain everywhere (D-239 §8 Lava law). +/// - `Barren` vegetation — fresh lava is lifeless. +/// - Low-relief base elevation (shield — gentle radiating slopes, not peaks). +/// - Micro-relief: coarse lava block texture from seed bits. +/// - Tube voids: seed-gated depressions 1–3 m below the surface (immature +/// drainage; no active river network — D-239 §8 immature drainage law). +/// - No active water channel regardless of `chunk.has_active_channel` — +/// immature drainage means no organised river network on fresh lava. +fn generate_lava_field( + region: &RegionProfile, + _voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Base elevation ───────────────────────────────────────────────────── + // Shield slope: gentle radiating dome. Use elev_q for the regional height; + // the shield adds a small parabolic falloff from the centre. At voxel scale + // we simulate this as a low-amplitude multi-scale noise on a flat base. + // Integer arithmetic throughout (D-010). + let base_elev_m = region.elev_q / 2; + + // Coarse lava block texture: bits [0:2] of seed → ±2 m vertical scatter. + let lava_block = (sub_chunk_seed & 0x7) as i32 - 3; // [−3, +4] + + // Lava tube void: 1-in-8 chance (bits [3:5] == 0b000) of a depression. + // Tubes are below-surface drainage (D-239 §8 immature drainage). + let tube_depression = if (sub_chunk_seed >> 3) & 0x7 == 0 { + let depth = ((sub_chunk_seed >> 6) & 0x3) as i32 + 1; // 1–4 m depression + -depth + } else { + 0 + }; + + let elevation_m = (base_elev_m + lava_block + tube_depression).max(0); + + // ── Tube void water state ────────────────────────────────────────────── + // A lava tube depression may collect a shallow puddle (moisture-gated). + // Very rare — only in humid zones (moisture_q ≥ 70). No Deep water on + // shield slopes (immature drainage has no deep organised channels). + let water = if tube_depression < -2 && region.moisture_q >= 70 { + Water::Shallow + } else { + Water::Dry + }; + + VoxelColumn { + terrain: TerrainMaterial::Lava, + floor: FloorMaterial::None, + vegetation: Vegetation::Barren, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// FjordWall generator (T-1029, D-239 §5 / §8 glaciation→form law) +// --------------------------------------------------------------------------- + +/// FjordWall voxel generator. +/// +/// Glacial U-valley topology: +/// - `Rock` terrain (D-239 §8 Rock→vertical faces law). +/// - Steep valley walls meeting deep water in the channel (the fjord). +/// - U-valley cross-section: flat (deep) bottom + near-vertical walls. +/// - GlaciationGrade ≥ 2 produces cirques (D-239 §5/§8 glaciation→form law). +/// - Moraines (debris mounds) at the valley sides for Grade ≥ 1. +/// - Valley floor is narrow (chokepoint — D-239 §9) with `Deep` water. +/// - Walls rise steeply; rocky, very little vegetation. +/// +/// The fjord inlet occupies the centre of the chunk (along `basin_direction`). +/// Cross-section: wall | moraine | Deep water | moraine | wall. +fn generate_fjord_wall( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Cross-valley coordinate ─────────────────────────────────────────── + // Fjord runs along basin_direction; cross = perpendicular distance from + // the centre-line. We fold the absolute cross-distance to get a positive + // distance in [0, 32] (chunk half-width = 32 m). + let (along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1), + }; + + // Cross-channel absolute distance from centreline (fold into positive). + // We use modulo 64 to map the absolute coordinate into the chunk frame. + let cross_from_centre = { + let rel = cross.rem_euclid(64) - 32; // −32..+31 + rel.abs() // 0..32 + }; + + // ── U-valley geometry ───────────────────────────────────────────────── + // The fjord bottom is 4–8 m wide (chokepoint, D-239 §9 "gorge floors 2–8 m"). + // Deep water within floor_half m of centre; moraine band beyond; wall beyond that. + // + // GlaciationGrade modifies the floor width, wall steepness, and cirque presence. + // Upstream classifier gate guarantees GlaciationGrade ≥ 2 for FjordWall (D-239 §5). + let glacier_grade = region.glaciation_grade as i32; // guaranteed ≥ 2 + + // Fjord floor half-width: 2–4 m (total 4–8 m, D-239 §9 compliant). + // Stronger glaciation carves a wider U-trough, but stays within §9 spec. + let floor_half = glacier_grade.clamp(2, 4); // total 4–8 m + // Moraine band extends from floor edge to moraine_outer. + let moraine_outer = floor_half + 6 + glacier_grade; // 10–14 m from centre + + // Base elevation for the wall platform — high ground context. + let wall_elev_base = (region.elev_q / 2).max(20); // at least 20 m walls + + let (elevation_m, water, vegetation) = if cross_from_centre <= floor_half { + // ── Fjord floor — deep water ───────────────────────────────────── + // Below sea level (drainage monotonicity: mouth at sea level). + // Elevation 0 m (sea level). + let floor_noise = (sub_chunk_seed & 0x1) as i32; // ±0–1 m + let elev = floor_noise; // 0 or 1 m at most + (elev, Water::Deep, Vegetation::Barren) + } else if cross_from_centre <= moraine_outer { + // ── Moraine band — rocky debris mound ──────────────────────────── + // Moraines always present — upstream classifier gate guarantees + // GlaciationGrade ≥ 2 (D-239 §5). Slight elevation bump; Rock substrate. + let moraine_noise = ((sub_chunk_seed >> 2) & 0x3) as i32; // 0–3 m + let moraine_dist = cross_from_centre - floor_half; + // Bell-shape: rises then falls across the band. + let half_band = (moraine_outer - floor_half) / 2; + let moraine_height = if moraine_dist <= half_band { + (moraine_dist * 3) / half_band.max(1) // 0 → 3 m rise + } else { + 3 - ((moraine_dist - half_band) * 3) / half_band.max(1) // 3 → 0 m fall + }; + let elev = (wall_elev_base / 4 + moraine_height + moraine_noise).max(0); + // Moraines hold some Shallow pools (meltwater) in humid zones. + let water = if region.moisture_q >= 60 && moraine_noise >= 2 { + Water::Shallow + } else { + Water::Dry + }; + (elev, water, Vegetation::Barren) + } else { + // ── Valley wall — near-vertical face ───────────────────────────── + // Rock→vertical faces (D-239 §8). Elevation rises steeply from + // moraine_outer to the wall top. + let wall_dist = cross_from_centre - moraine_outer; + // Steep linear rise: 6 m per metre of cross-distance (approximates vertical). + let wall_rise = wall_dist * 6; + // Cirques (grade ≥ 2): occasional hollowed pocket (2–4 m depression) at + // upper wall. seed-gated, ~1-in-8 frequency. + let cirque_depression = if glacier_grade >= 2 && (sub_chunk_seed >> 6) & 0x7 == 0 { + // 2–5 m + ((sub_chunk_seed >> 9) & 0x3) as i32 + 2 + } else { + 0 + }; + // Wall noise: jagged rock face (bits [1:3]). + let wall_noise = ((sub_chunk_seed >> 1) & 0x7) as i32 - 4; // [−4, +3] + let elev = (wall_elev_base + wall_rise + wall_noise - cirque_depression).max(0); + // Very sparse vegetation on high rocky walls. + let veg = match region.vegetation_class { + VegetationClass::Forest | VegetationClass::Scrub => { + if (sub_chunk_seed >> 12) & 0xF < 2 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + _ => Vegetation::Barren, + }; + (elev, Water::Dry, veg) + }; + + // ── Along-axis variation (avalanche debris / longitudinal texture) ──── + // Use along coordinate modulo the meander wavelength for slow variation. + let _along_used = along; // structural reference; prevents unused-variable warning + + VoxelColumn { + terrain: TerrainMaterial::Rock, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// CliffCoast generator (T-1029, D-239 §5 / §8 Rock law) +// --------------------------------------------------------------------------- + +/// CliffCoast voxel generator. +/// +/// Vertical rock face at the coastal water edge: +/// - `Rock` terrain (D-239 §8 Rock→vertical faces law). +/// - Sharp elevation drop to `Shallow`/`Deep` water at the base. +/// - The coast runs perpendicular to `basin_direction`; inland is high, +/// seaward is near-zero. +/// - Narrow ledge/platform at the cliff base (passable ground, Shallow). +/// - Virtually no flat ground (D-239 §8 — Rock→vertical, not terraced). +fn generate_cliff_coast( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Coast orientation ───────────────────────────────────────────────── + // basin_direction points seaward (water flows to ocean). The seaward axis + // is the "along-flow" direction; cross = coast face direction. + // Cross-coordinate from the chunk centre in [−32, +31]. + let seaward_coord = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North => -voxel_pos.1, + crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1, + crate::atlas::chunk_context::BasinDirection::East => voxel_pos.0, + crate::atlas::chunk_context::BasinDirection::West => -voxel_pos.0, + }; + + // Normalise into chunk frame [0, 63] with seaward = 63. + let seaward_norm = (seaward_coord.rem_euclid(64)).clamp(0, 63); + + // ── Cliff face ──────────────────────────────────────────────────────── + // The cliff face is at seaward_norm ≈ 48–55 (roughly 3/4 across the chunk). + // Inland (0..47): high ground, rocky top. + // Face (48..55): steep drop (>6 m per metre — near-vertical, D-239 §8). + // Ledge (56..60): rocky beach platform, Shallow. + // Ocean (61..63): Deep water. + // + // Cliff height derived from region elevation. + let cliff_top_elev = (region.elev_q / 2).max(15); // at least 15 m cliff + + // Rock-face jaggedness noise: bits [0:2] → ±3 m. + let face_noise = (sub_chunk_seed & 0x7) as i32 - 4; + // Ledge variation noise: bits [3:5] → 0–2 m. + let ledge_noise = ((sub_chunk_seed >> 3) & 0x3) as i32; + + let (elevation_m, water) = if seaward_norm < 48 { + // Inland high ground — rocky plateau. + let inland_noise = ((sub_chunk_seed >> 5) & 0xF) as i32 - 7; // [−7, +8] + let elev = (cliff_top_elev + inland_noise).max(cliff_top_elev / 4); + (elev, Water::Dry) + } else if seaward_norm < 56 { + // Cliff face — steep drop. Each metre seaward drops ~cliff_top/8 metres. + let face_dist = seaward_norm - 48; // 0–7 + let drop = (face_dist * cliff_top_elev) / 8; + let elev = (cliff_top_elev - drop + face_noise).max(1); + (elev, Water::Dry) + } else if seaward_norm < 61 { + // Rocky ledge / splash zone. + let elev = (1 + ledge_noise).max(0); + (elev, Water::Shallow) + } else { + // Ocean — drainage monotonicity: mouths at sea level (elev 0). + (0, Water::Deep) + }; + + // Vegetation: nearly barren on rock faces; tiny scatter of Scrub on inland top. + let vegetation = match region.vegetation_class { + VegetationClass::Forest | VegetationClass::RiparianThicket => { + if seaward_norm < 30 && (sub_chunk_seed >> 10) & 0x7 < 3 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + VegetationClass::Scrub | VegetationClass::RiparianScrub => { + if seaward_norm < 20 && (sub_chunk_seed >> 10) & 0x3 == 0 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + _ => Vegetation::Barren, + }; + + VoxelColumn { + terrain: TerrainMaterial::Rock, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// BraidedDelta generator (T-1029, D-239 §5 / §8 Gravel law) +// --------------------------------------------------------------------------- + +/// BraidedDelta voxel generator. +/// +/// Braided channel / fan topology: +/// - `Gravel` terrain (D-239 §8 Gravel→braided channels/fans law). +/// - Multiple shallow distributary channels (NOT single-thread meander). +/// - Very low elevation — near sea level (D-239 §8 drainage monotonicity: +/// mouths at sea level). +/// - Channel determination: 3 independent braided threads, each offset by +/// ~1/3 of the chunk width and width 3–8 m. Thread presence is seeded at +/// region scale (meander_phase encodes thread offsets). +/// - `Shallow` in-channel; `Dry` on gravel bars between threads. +fn generate_braided_delta( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Base elevation ──────────────────────────────────────────────────── + // Delta is near sea level. elev_q typically very low in delta zones; cap + // at 5 m to honour drainage monotonicity (mouths at sea level). + let base_elev_m = (region.elev_q / 4).min(5); // 0–5 m; near sea level + // Gravel bar micro-relief: ±1 m scatter on bars between channels. + let bar_noise = (sub_chunk_seed & 0x3) as i32; // 0–3 + + // ── Braided thread geometry ─────────────────────────────────────────── + // Cross-channel coordinate (perpendicular to basin_direction). + let (_along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1), + }; + + // Map cross into chunk frame [0, 63]. + let cross_norm = cross.rem_euclid(64); + + // Three braided threads with well-separated centres derived from meander_phase. + // Thread centres spread across the chunk width (D-239 §8 Gravel→braided). + // Each centre uses independent bit-mixing (splitmix64-style) so threads are + // provably separated regardless of phase value — no correlated bit-slices. + let phase_u64 = chunk.meander_phase as u64; + // Mix A: phase itself through splitmix64 finaliser. + let mix_a = { + let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15); + h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb); + h ^ (h >> 31) + }; + // Mix B: phase XOR'd with a distinct salt before mixing. + let mix_b = { + let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15) ^ 0xdeadbeefcafe1234; + h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb); + h ^ (h >> 31) + }; + // Mix C: phase XOR'd with a second distinct salt. + let mix_c = { + let mut h = phase_u64.wrapping_add(0x9e3779b97f4a7c15) ^ 0xfeedface0badcafe; + h = (h ^ (h >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + h = (h ^ (h >> 27)).wrapping_mul(0x94d049bb133111eb); + h ^ (h >> 31) + }; + let thread_centres: [i32; 3] = [ + (mix_a % 64) as i32, + (mix_b % 64) as i32, + (mix_c % 64) as i32, + ]; + + // Thread width: 3–6 m half-width derived from channel_width_m. + let thread_half = (chunk.channel_width_m / 2).clamp(2, 4); + + // Check if voxel is in any braided thread. + // True modular distance on a [0, 64) ring — handles wrap at both edges. + let in_any_thread = thread_centres.iter().any(|¢re| { + let d = (cross_norm - centre).rem_euclid(64); + let dist = d.min(64 - d); + dist <= thread_half + }); + + let (elevation_m, water) = if in_any_thread { + // In a distributary channel — at or just below base elevation. + // Channels are cut 1 m below gravel bars (D-239 §9 ElevationDelta). + let channel_elev = (base_elev_m - 1).max(0); + (channel_elev, Water::Shallow) + } else { + // Gravel bar between channels — slightly above channel floor. + (base_elev_m + bar_noise.min(1), Water::Dry) + }; + + // Near the delta mouth (elev = 0), some bars are submerged (Shallow). + let water = if elevation_m == 0 && !in_any_thread && bar_noise == 0 { + Water::Shallow + } else { + water + }; + + // Vegetation: riparian scrub on stable bars; barren on active channel sediment. + let vegetation = if !in_any_thread && region.moisture_q >= 50 { + match region.vegetation_class { + VegetationClass::Forest + | VegetationClass::RiparianThicket + | VegetationClass::RiparianScrub => { + if (sub_chunk_seed >> 4) & 0x7 < 3 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + VegetationClass::Scrub => { + if (sub_chunk_seed >> 4) & 0x3 == 0 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + _ => Vegetation::Barren, + } + } else { + Vegetation::Barren + }; + + VoxelColumn { + terrain: TerrainMaterial::Gravel, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// DuneStrand generator (T-1029, D-239 §5 / §8 Sand law) +// --------------------------------------------------------------------------- + +/// DuneStrand voxel generator. +/// +/// Wind-formed dune topology: +/// - `Sand` terrain (D-239 §8 Sand→≤~32° angle of repose, dunes not cliffs). +/// - Wind-aligned ridges (orientation derived from `basin_direction`). +/// - Dune crests and troughs — amplitude ≤ what a 32° slope permits at this +/// scale (slope constraint: at most 1 m rise per ~1.6 m horizontal run). +/// - Mostly `Dry`; `Shallow` at the seaward toe where surf meets sand. +/// - `Barren` or very sparse vegetation (coastal strand). +fn generate_dune_strand( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Dune orientation ────────────────────────────────────────────────── + // Dunes are wind-aligned; dune ridges run perpendicular to basin_direction + // (wind blows onshore, ridges parallel the shoreline). + // Along-wind = basin_direction; dune crest height varies along-wind. + let (along_wind, cross_wind) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1), + }; + + // ── Dune wavelength ─────────────────────────────────────────────────── + // Dune spacing is shorter than meander wavelength; derive from slope_q. + // Low slope → long dunes (8–20 m wavelength); high slope → shorter (4–8 m). + // Integer arithmetic (D-010). + let dune_wavelength = { + let slope_clamped = region.slope_q.clamp(0, 40); + 20 - slope_clamped / 2 // 20 m flat → 4 m at slope 32 + } + .max(4); + + // Phase offset from meander_phase (region-scale, D-239 §10). + let phase = chunk.meander_phase as i32; + let along_mod = (along_wind + phase).rem_euclid(dune_wavelength); + + // ── Dune cross-section height ───────────────────────────────────────── + // Triangle-wave: ramp from trough to crest and back over one wavelength. + // D-239 §8 Sand law: ≤32° angle of repose enforced by the integer physics cap: + // tan(32°) ≈ 0.625 → max_height ≤ 0.625 × (wavelength/2). + // The integer form `(wavelength * 625) / 2000` directly encodes this. + // Also capped by slope_q/4 (region terrain height proxy) to give local variety. + let max_height = { + let physics_cap = (dune_wavelength * 625) / 2000; // tan(32°)×wavelength/2, integer + let region_cap = (region.slope_q / 4).max(1); // region terrain height proxy + physics_cap.min(region_cap).max(1) + }; + let half_wl = dune_wavelength / 2; + let dune_height = if along_mod < half_wl { + (2 * max_height * along_mod) / dune_wavelength.max(1) // 0 → max + } else { + max_height - ((along_mod - half_wl) * max_height) / half_wl.max(1) // max → 0 + }; + + // ── Base elevation ──────────────────────────────────────────────────── + // Beach/strand is near sea level; add dune height above that base. + // Cross-wind variation: sub-dune bar variation (±1 m). + let cross_noise = cross_wind.abs() & 0x1; // 0 or 1 + // Bits [4:6] of seed → ±1 m additional scatter on the dune surface. + let surface_scatter = ((sub_chunk_seed >> 4) & 0x3) as i32 - 1; // [−1, +2] + + let base_strand_elev = (region.elev_q / 8).max(0); // 0–12 m strand base + let elevation_m = (base_strand_elev + dune_height + cross_noise + surface_scatter).max(0); + + // ── Water state ─────────────────────────────────────────────────────── + // Shallow at the strand toe (base elev = 0) and in dune troughs near + // the coast. Dry elsewhere. + let water = if elevation_m == 0 || (dune_height == 0 && base_strand_elev <= 1) { + Water::Shallow // surf line / wave-washed troughs + } else { + Water::Dry + }; + + // ── Vegetation ──────────────────────────────────────────────────────── + // Dunes are mostly barren; scattered dune grass on stable crests + // in humid zones. No trees (wind/salt prevents forest). + let vegetation = if region.moisture_q >= 55 && dune_height >= max_height / 2 { + if (sub_chunk_seed >> 8) & 0x7 < 2 { + Vegetation::Grass // dune grass on humid crests + } else { + Vegetation::Barren + } + } else { + Vegetation::Barren + }; + + VoxelColumn { + terrain: TerrainMaterial::Sand, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// IncisedGorge generator (T-1029, D-239 §5 / §8 / §9) +// --------------------------------------------------------------------------- + +/// IncisedGorge voxel generator — shared by MountainPass zones. +/// +/// Deep narrow gorge: +/// - `Rock` terrain (D-239 §8 Rock→vertical faces). +/// - Steep walls; single incised channel at the gorge floor. +/// - **Gorge floor 2–8 m wide** (D-239 §9 chokepoint constraint). +/// - Massive elevation differential: walls are 20–60 m above the floor. +/// - `Shallow` water in the incised channel at the floor. +/// - Cross-section: wall | wall | narrow floor (with channel) | wall | wall. +fn generate_incised_gorge( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Cross-gorge coordinate ───────────────────────────────────────────── + let (_along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1), + }; + + // Distance from gorge centre-line in [0, 32]. + let cross_from_centre = { + let rel = cross.rem_euclid(64) - 32; // −32..+31 + rel.abs() + }; + + // ── Gorge floor width (D-239 §9: 2–8 m) ────────────────────────────── + // Floor half-width: 1–4 m (total 2–8 m). Derived from channel_width_m + // (capped to 4 m half-width to stay within §9). + let floor_half = chunk.channel_width_m.clamp(1, 4); // 1–4 m half-width + + // ── Wall geometry ───────────────────────────────────────────────────── + // The wall rises steeply: each metre away from the floor adds ~8 m of + // height. Wall top at region elev_q. + let wall_top = (region.elev_q / 2).max(30); // at least 30 m wall height + let floor_elev = (region.elev_q / 8).max(0); // floor is much lower than surroundings + + // Rock wall jaggedness: bits [0:3] → ±4 m. + let wall_noise = (sub_chunk_seed & 0xF) as i32 - 8; // [−8, +7] + // Floor noise: bits [4:5] → ±1 m floor scatter. + let floor_noise = ((sub_chunk_seed >> 4) & 0x3) as i32 - 1; // [−1, +2] + + let (elevation_m, water) = if cross_from_centre <= floor_half { + // ── Gorge floor — narrow and deep ───────────────────────────────── + // Active incised channel (D-239 §9: "single incised channel"). + // The channel runs along the floor; water is Shallow. + let elev = (floor_elev + floor_noise).max(0); + (elev, Water::Shallow) + } else { + // ── Gorge wall — near-vertical face ─────────────────────────────── + // Elevation rises steeply: each metre of cross-distance from floor edge + // adds wall_step metres. We model this as a steep ramp. + let wall_dist = cross_from_centre - floor_half; + // Steep wall: 8 m rise per metre of wall distance (near-vertical, §8 Rock law). + let wall_rise = wall_dist * 8; + let elev = (floor_elev + wall_rise + wall_noise).clamp(floor_elev, wall_top); + (elev, Water::Dry) + }; + + // Vegetation: barren in the gorge (shadow / rock). Tiny scatter of Scrub + // on the upper wall rim in humid temperate zones. + let vegetation = if cross_from_centre > (floor_half + 20) && region.moisture_q >= 60 { + match region.vegetation_class { + VegetationClass::Forest | VegetationClass::RiparianThicket => { + if (sub_chunk_seed >> 8) & 0x7 < 2 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + VegetationClass::Scrub => { + if (sub_chunk_seed >> 8) & 0x3 == 0 { + Vegetation::Scrub + } else { + Vegetation::Barren + } + } + _ => Vegetation::Barren, + } + } else { + Vegetation::Barren + }; + + VoxelColumn { + terrain: TerrainMaterial::Rock, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +// --------------------------------------------------------------------------- +// MeanderReach generator (T-1029, D-239 §5 / §8 / §9) +// --------------------------------------------------------------------------- + +/// MeanderReach voxel generator. +/// +/// Single-thread meandering river floodplain: +/// - `Soil` terrain (D-239 §8 Soil→rolling/floodplain law). +/// - Stronger single meander than AlluvialPlain (higher sinuosity). +/// - `river crossings 3–15 m` width (D-239 §9 game-feel constraint). +/// - Levees above / channel below the floodplain surface +/// (D-239 §9 ElevationDelta: channels below, levees above high-water threshold). +/// - Floodplain vegetation from region class with riparian band at waterway. +fn generate_meander_reach( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Terrain material ─────────────────────────────────────────────────── + // MeanderReach → Soil. Wetland sub-zone possible when very wet and flat. + let terrain = if matches!(region.morphology_zone, MorphologyZone::Wetland) + || (region.slope_q <= 3 && region.moisture_q >= 70) + { + TerrainMaterial::Wetland + } else { + TerrainMaterial::Soil + }; + + // ── Base elevation ────────────────────────────────────────────────────── + // MeanderReach is a low-gradient floodplain; similar to AlluvialPlain but + // typically lower (mature river valley). Map elev_q → metres with a + // slightly lower ceiling than AlluvialPlain. + let base_elev_m = region.elev_q / 3; // 0–33 m range (lower than Alluvial's /2) + let micro_relief = (sub_chunk_seed & 0x7) as i32 - 4; // [−4, +3] m + let floodplain_elev = (base_elev_m + micro_relief).max(0); + + // ── Stronger meander channel (D-239 §9: river crossings 3–15 m) ─────── + // MeanderReach uses higher sinuosity than AlluvialPlain. We achieve this + // by increasing the effective amplitude multiplier from 1/6 → 1/4 of + // wavelength, producing ~25% sinuosity (vs ~17% for AlluvialPlain). + // + // Channel computation mirrors AlluvialPlain's compute_channel_state but + // with the stronger amplitude. + let (in_channel, is_deep) = if chunk.has_active_channel { + compute_meander_reach_channel(voxel_pos, chunk, sub_chunk_seed) + } else { + (false, false) + }; + + // ── Levee / channel elevation (D-239 §9 ElevationDelta) ─────────────── + // Levees are natural berms built up by flood deposition, elevated above + // the floodplain. Channels cut below. Elevation relationship: + // levee_top > floodplain > channel_floor (D-239 §9: "levees above, + // channels below the high-water threshold"). + let (elevation_m, water, vegetation) = if is_deep { + // Deep channel thalweg — 3 m below floodplain. + let elev = (floodplain_elev - 3).max(0); + (elev, Water::Deep, Vegetation::Thicket) + } else if in_channel { + // Shallow channel margin — 2 m below floodplain. + let elev = (floodplain_elev - 2).max(0); + // Riparian thicket along the channel bank (D-239 §8 climate→vegetation). + let veg = match region.vegetation_class { + VegetationClass::Absent | VegetationClass::Barren => Vegetation::Barren, + _ => Vegetation::Thicket, + }; + (elev, Water::Shallow, veg) + } else { + // ── Off-channel floodplain ──────────────────────────────────────── + // Check for levee proximity: within 3 m of channel edge → levee. + // Levee is elevated 1–2 m above floodplain. + let levee_elev = { + let levee_extra = if in_levee_band(voxel_pos, chunk, sub_chunk_seed) { + // 1–4 m + ((sub_chunk_seed >> 10) & 0x3) as i32 + 1 + } else { + 0 + }; + floodplain_elev + levee_extra + }; + + // Vegetation from region class; riparian thicket near the levee. + let base_veg = Vegetation::from_vegetation_class(region.vegetation_class); + let vegetation = scatter_vegetation(base_veg, sub_chunk_seed); + (levee_elev, Water::Dry, vegetation) + }; + + VoxelColumn { + terrain, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +/// Compute channel state for MeanderReach — higher sinuosity than AlluvialPlain. +/// +/// Uses amplitude = wavelength/4 (~25% sinuosity) vs AlluvialPlain's /6 (~17%). +/// This produces a more pronounced meander — a wider swing across the floodplain, +/// creating more tactical variety (D-239 §9 game-feel). +fn compute_meander_reach_channel( + voxel_pos: VoxelPos, + chunk: &ChunkContext, + sub_chunk_seed: u64, +) -> (bool, bool) { + let (vx, vy) = voxel_pos; + + let (along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (vy, vx), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (vx, vy), + }; + + let wavelength_m = (chunk.meander_wavelength_m as i32).max(10); + let phase_offset = chunk.meander_phase as i32; + let angle_mod = (along + phase_offset).rem_euclid(wavelength_m); + + // MeanderReach amplitude: 1/4 of wavelength (stronger meander than AlluvialPlain). + let amplitude = (wavelength_m / 4).max(3); // at least ±3 m amplitude + let half_wl = wavelength_m / 2; + let meander_displacement = if angle_mod < half_wl { + (2 * amplitude * angle_mod) / half_wl.max(1) - amplitude + } else { + amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1) + }; + + let perp_distance = (cross - meander_displacement).abs(); + + // Edge jitter from sub-chunk seed (±1 m), same as AlluvialPlain. + let edge_jitter = (sub_chunk_seed & 0x3) as i32; + let half_width = (chunk.channel_width_m / 2).max(2); // MeanderReach channels at least 4 m total + let channel_edge = half_width + edge_jitter; + let deep_edge = (half_width / 2).max(1); + + let in_channel = perp_distance <= channel_edge; + let is_deep = perp_distance <= deep_edge; + + (in_channel, is_deep) +} + +/// Determine if a voxel is in the levee band adjacent to the MeanderReach channel. +/// +/// Levees are natural berms 3–8 m wide on each bank, just outside the channel +/// edge. They are elevated 1–4 m above the floodplain. The levee band is the +/// zone from channel_edge to channel_edge + levee_width. +fn in_levee_band(voxel_pos: VoxelPos, chunk: &ChunkContext, sub_chunk_seed: u64) -> bool { + let (vx, vy) = voxel_pos; + + let (along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (vy, vx), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (vx, vy), + }; + + let wavelength_m = (chunk.meander_wavelength_m as i32).max(10); + let phase_offset = chunk.meander_phase as i32; + let angle_mod = (along + phase_offset).rem_euclid(wavelength_m); + + let amplitude = (wavelength_m / 4).max(3); + let half_wl = wavelength_m / 2; + let meander_displacement = if angle_mod < half_wl { + (2 * amplitude * angle_mod) / half_wl.max(1) - amplitude + } else { + amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1) + }; + + let perp_distance = (cross - meander_displacement).abs(); + + let edge_jitter = (sub_chunk_seed & 0x3) as i32; + let half_width = (chunk.channel_width_m / 2).max(2); + let channel_edge = half_width + edge_jitter; + let levee_width = 4 + ((sub_chunk_seed >> 6) & 0x3) as i32; // 4–7 m levee band + + // In levee band: just outside channel, within levee_width metres of channel edge. + perp_distance > channel_edge && perp_distance <= channel_edge + levee_width +} + /// Determine if a voxel is in the meander channel, and if it is deep. /// /// The channel is a sine-wave path oriented along `chunk.basin_direction`. @@ -1072,88 +1835,913 @@ mod tests { // Family dispatch stubs — must not panic // ----------------------------------------------------------------------- - #[test] - fn lava_family_stub_does_not_panic() { - let region = RegionProfile { + // ----------------------------------------------------------------------- + // T-1029 — LavaField + // ----------------------------------------------------------------------- + + fn lava_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::Volcanic, tectonic_class: TectonicClass::Volcanic, vegetation_class: VegetationClass::Barren, ocean_fraction_q: 0, + slope_q: 8, + elev_q: 30, + moisture_q: 20, ..alluvial_region() - }; + } + } + + #[test] + fn lava_field_terrain_is_lava() { + let region = lava_region(); let chunk = derive_chunk_context(42, "Io", ®ion, (0, 0)); - let col = derive_voxel_column(42, "Io", ®ion, &chunk, 10, 10); - assert_eq!( - col.terrain, - TerrainMaterial::Lava, - "LavaField stub must set Lava terrain" - ); - assert_eq!(col.floor, FloorMaterial::None); + for pos in [(10, 10), (0, 0), (50, 25), (-5, 5)] { + let col = derive_voxel_column(42, "Io", ®ion, &chunk, pos.0, pos.1); + assert_eq!( + col.terrain, + TerrainMaterial::Lava, + "LavaField must produce Lava terrain at {:?}", + pos + ); + } } #[test] - fn fjord_family_stub_does_not_panic() { - let region = RegionProfile { + fn lava_field_vegetation_is_barren() { + let region = lava_region(); + let chunk = derive_chunk_context(42, "Io", ®ion, (0, 0)); + for tx in 0..20i32 { + let col = derive_voxel_column(42, "Io", ®ion, &chunk, tx, 10); + assert_eq!( + col.vegetation, + Vegetation::Barren, + "LavaField must be Barren at x={tx}" + ); + } + } + + #[test] + fn lava_field_no_deep_water() { + // Immature drainage law (D-239 §8): no deep organised channels on lava. + let region = lava_region(); + let chunk = derive_chunk_context(42, "Io", ®ion, (0, 0)); + for (tx, ty) in (0..50).map(|i| (i * 7, i * 3)) { + let col = derive_voxel_column(42, "Io", ®ion, &chunk, tx, ty); + assert_ne!( + col.water, + Water::Deep, + "LavaField must have no Deep water (immature drainage)" + ); + } + } + + #[test] + fn lava_field_is_deterministic() { + let region = lava_region(); + let chunk = derive_chunk_context(7, "Io", ®ion, (5, 3)); + for (tx, ty) in [(0, 0), (10, 20), (-5, 7)] { + let a = derive_voxel_column(7, "Io", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(7, "Io", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "LavaField must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn lava_field_elevation_non_negative() { + let region = lava_region(); + let chunk = derive_chunk_context(42, "Io", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "Io", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "LavaField elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — FjordWall + // ----------------------------------------------------------------------- + + fn fjord_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::Fjord, + tectonic_class: TectonicClass::Active, + glaciation_grade: GlaciationGrade::Moderate, // grade ≥ 2 + slope_q: 60, + elev_q: 80, + ocean_fraction_q: 30, + moisture_q: 70, + vegetation_class: VegetationClass::Barren, ..alluvial_region() - }; - let chunk = derive_chunk_context(42, "fjord_body", ®ion, (0, 0)); - let _col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, 10, 10); - // No panic = pass. + } } #[test] - fn cliff_coast_family_stub_does_not_panic() { - let region = RegionProfile { + fn fjord_wall_terrain_is_rock() { + let region = fjord_region(); + let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0)); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] { + let col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty); + assert_eq!( + col.terrain, + TerrainMaterial::Rock, + "FjordWall must produce Rock terrain at ({tx},{ty})" + ); + } + } + + #[test] + fn fjord_wall_centre_has_deep_water() { + // The fjord inlet (centreline of the chunk) should have Deep water. + let region = fjord_region(); + // Use a chunk centred around x=0, basin direction North/South. + // The fjord centre is at cross = 0 relative to chunk centre. + // Cross coordinate = x, so at x=32 (the mid-chunk point in [0,63]). + // We sample at tile_x=32 to hit the centre. + let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0)); + // Guard: this sweep assumes N/S basin (cross axis = tile_x). If the seed + // yields E/W the test would sweep the wrong axis and pass vacuously. + assert!( + matches!( + chunk.basin_direction, + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South + ), + "test assumes N/S basin direction; pick a different seed if this fires" + ); + // Find a position near the fjord centreline. We pick x positions that + // map to cross_from_centre = 0 (chunk centre). At x=0 in the grid, + // rem_euclid(64) - 32 = -32 → abs=32. At x=32, rem_euclid(64)=32, 32-32=0. + // So tile_x near 32 (in basin_direction perpendicular axis for North). + // For North/South basin direction: cross = voxel_pos.0. + let col_centre = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 32, 50); + assert_eq!( + col_centre.water, + Water::Deep, + "FjordWall centreline must have Deep water (the fjord inlet)" + ); + } + + #[test] + fn fjord_wall_walls_are_high_relative_to_floor() { + // Wall elevation must be substantially higher than fjord floor. + let region = fjord_region(); + let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0)); + // Guard: this test assumes N/S basin (cross axis = tile_x). + assert!( + matches!( + chunk.basin_direction, + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South + ), + "test assumes N/S basin direction; pick a different seed if this fires" + ); + // Floor at centre (x=32 for North/South basin). + let floor_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 32, 50); + // Wall far from centre (x = 0 or x = 63 = cross_from_centre ≥ 32). + let wall_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 0, 50); + assert!( + wall_col.elevation_m > floor_col.elevation_m, + "FjordWall walls ({}) must be higher than the floor ({})", + wall_col.elevation_m, + floor_col.elevation_m + ); + } + + #[test] + fn fjord_wall_is_deterministic() { + let region = fjord_region(); + let chunk = derive_chunk_context(11, "Fjordheim", ®ion, (2, 3)); + for (tx, ty) in [(32, 50), (0, 50), (63, 20)] { + let a = derive_voxel_column(11, "Fjordheim", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(11, "Fjordheim", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "FjordWall must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn fjord_wall_elevation_non_negative() { + let region = fjord_region(); + let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "FjordWall elevation must be >= 0"); + } + } + + #[test] + fn fjord_wall_floor_width_in_spec() { + // D-239 §9: fjord floor (Deep water) must be 2–8 m wide across a cross-section. + // With floor_half = glacier_grade.clamp(2,4) and grade ≥ 2 (upstream gate), + // total Deep-water width = 2×floor_half + noise ∈ [4, 8] m. + let region = fjord_region(); // glaciation_grade = Moderate (= 2) + let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0)); + // Guard: sweep assumes N/S basin (cross axis = tile_x). + assert!( + matches!( + chunk.basin_direction, + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South + ), + "test assumes N/S basin direction; pick a different seed if this fires" + ); + // Count Deep-water tiles across the 64-tile cross-section. + let deep_tiles = (0..64i32) + .filter(|&x| { + derive_voxel_column(42, "Fjordheim", ®ion, &chunk, x, 50).water == Water::Deep + }) + .count(); + assert!( + (2..=8).contains(&deep_tiles), + "FjordWall floor width {deep_tiles} m outside [2, 8] m spec (D-239 §9)" + ); + } + + // ----------------------------------------------------------------------- + // T-1029 — CliffCoast + // ----------------------------------------------------------------------- + + fn cliff_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::CliffCoast, + tectonic_class: TectonicClass::Active, + slope_q: 70, + elev_q: 60, + ocean_fraction_q: 40, + moisture_q: 50, + vegetation_class: VegetationClass::Barren, ..alluvial_region() - }; - let chunk = derive_chunk_context(42, "cliff_body", ®ion, (0, 0)); - let col = derive_voxel_column(42, "cliff_body", ®ion, &chunk, 10, 10); - assert_eq!(col.terrain, TerrainMaterial::Rock); + } } #[test] - fn braided_delta_family_stub_does_not_panic() { - let region = RegionProfile { + fn cliff_coast_terrain_is_rock() { + let region = cliff_region(); + let chunk = derive_chunk_context(42, "Velen", ®ion, (0, 0)); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] { + let col = derive_voxel_column(42, "Velen", ®ion, &chunk, tx, ty); + assert_eq!( + col.terrain, + TerrainMaterial::Rock, + "CliffCoast must produce Rock terrain at ({tx},{ty})" + ); + } + } + + #[test] + fn cliff_coast_steep_elevation_drop_near_water() { + // Verify the CliffCoast structural property: there must be tiles with Deep + // water AND there must be a significant elevation range (high ground + ocean). + // We don't hard-code which tile_x is "inland" vs "ocean" because basin_direction + // and domain warp interact — instead we sample a full transect and verify the + // property that: (a) some tiles have Deep water, (b) the max elevation in + // the transect exceeds the ocean elevation by > 10 m (the "vertical face" law). + let region = cliff_region(); + let chunk = derive_chunk_context(42, "Velen", ®ion, (0, 0)); + // Sample 64 cross-coast positions at a fixed along-coast coordinate. + let cols: Vec = (0..64i32) + .map(|x| derive_voxel_column(42, "Velen", ®ion, &chunk, x, 50)) + .collect(); + let has_deep = cols.iter().any(|c| c.water == Water::Deep); + let max_elev = cols.iter().map(|c| c.elevation_m).max().unwrap(); + let ocean_elev = cols + .iter() + .filter(|c| c.water == Water::Deep) + .map(|c| c.elevation_m) + .min() + .unwrap_or(0); + assert!( + has_deep, + "CliffCoast must have at least one tile with Deep water" + ); + assert!( + max_elev > ocean_elev + 5, + "CliffCoast: max elevation ({max_elev}) must be >> ocean elevation ({ocean_elev}) — vertical face law" + ); + } + + #[test] + fn cliff_coast_is_deterministic() { + let region = cliff_region(); + let chunk = derive_chunk_context(5, "Velen", ®ion, (1, 2)); + for (tx, ty) in [(5, 50), (30, 20), (62, 50)] { + let a = derive_voxel_column(5, "Velen", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(5, "Velen", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "CliffCoast must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn cliff_coast_elevation_non_negative() { + let region = cliff_region(); + let chunk = derive_chunk_context(42, "Velen", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "Velen", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "CliffCoast elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — BraidedDelta + // ----------------------------------------------------------------------- + + fn delta_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::Delta, + slope_q: 2, + elev_q: 4, + ocean_fraction_q: 50, + moisture_q: 80, + vegetation_class: VegetationClass::Scrub, ..alluvial_region() - }; + } + } + + #[test] + fn braided_delta_terrain_is_gravel() { + let region = delta_region(); let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); - let col = derive_voxel_column(42, "delta_body", ®ion, &chunk, 10, 10); - assert_eq!(col.terrain, TerrainMaterial::Gravel); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] { + let col = derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty); + assert_eq!( + col.terrain, + TerrainMaterial::Gravel, + "BraidedDelta must produce Gravel terrain at ({tx},{ty})" + ); + } } #[test] - fn dune_strand_family_stub_does_not_panic() { - let region = RegionProfile { + fn braided_delta_low_elevation() { + // Delta is near sea level — D-239 §8 drainage monotonicity: mouths at sea level. + let region = delta_region(); + let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5), (63, 0)] { + let col = derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty); + assert!( + col.elevation_m <= 8, + "BraidedDelta must be near sea level (<= 8 m), got {} at ({tx},{ty})", + col.elevation_m + ); + } + } + + #[test] + fn braided_delta_has_multiple_shallow_channels() { + // Gravel→braided (D-239 §8): NOT single-thread. Multiple channels means + // a range of positions across the chunk width should contain some Shallow water. + let region = delta_region(); + let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); + // Sample 64 positions across the chunk width (cross-section). + let shallow_count = (0..64i32) + .filter(|&x| { + derive_voxel_column(42, "delta_body", ®ion, &chunk, x, 100).water + == Water::Shallow + }) + .count(); + // Three braided threads, each 3–8 m wide → expect at least 6 shallow tiles + // in a 64-tile transect. + assert!( + shallow_count >= 6, + "BraidedDelta must have multiple braided channels; only {shallow_count} shallow in transect" + ); + } + + #[test] + fn braided_delta_no_single_thread() { + // BraidedDelta must NOT look like a single-thread meander — the channel + // should appear in more than one spatial cluster across the cross-section. + // We verify by checking that Shallow water appears in at least two + // disjoint groups separated by Dry ground. + let region = delta_region(); + let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); + let waters: Vec = (0..64i32) + .map(|x| derive_voxel_column(42, "delta_body", ®ion, &chunk, x, 100).water) + .collect(); + // Count Dry→Shallow transitions = number of channel entrances. + let transitions = waters + .windows(2) + .filter(|w| w[0] == Water::Dry && w[1] == Water::Shallow) + .count(); + assert!( + transitions >= 2, + "BraidedDelta must have ≥2 braided channel threads; only {transitions} transitions" + ); + } + + #[test] + fn braided_delta_is_deterministic() { + let region = delta_region(); + let chunk = derive_chunk_context(99, "delta_body", ®ion, (3, 1)); + for (tx, ty) in [(10, 10), (0, 100), (-5, 7)] { + let a = derive_voxel_column(99, "delta_body", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(99, "delta_body", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "BraidedDelta must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn braided_delta_elevation_non_negative() { + let region = delta_region(); + let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "BraidedDelta elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — DuneStrand + // ----------------------------------------------------------------------- + + fn dune_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::DuneStrand, + slope_q: 8, + elev_q: 6, + ocean_fraction_q: 30, + moisture_q: 40, + vegetation_class: VegetationClass::Barren, ..alluvial_region() - }; + } + } + + #[test] + fn dune_strand_terrain_is_sand() { + let region = dune_region(); let chunk = derive_chunk_context(42, "dune_body", ®ion, (0, 0)); - let col = derive_voxel_column(42, "dune_body", ®ion, &chunk, 10, 10); - assert_eq!(col.terrain, TerrainMaterial::Sand); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] { + let col = derive_voxel_column(42, "dune_body", ®ion, &chunk, tx, ty); + assert_eq!( + col.terrain, + TerrainMaterial::Sand, + "DuneStrand must produce Sand terrain at ({tx},{ty})" + ); + } } #[test] - fn incised_gorge_family_stub_does_not_panic() { - let region = RegionProfile { + fn dune_strand_respects_angle_of_repose() { + // D-239 §8 Sand law: ≤~32° angle of repose. For a dune with period W, + // the maximum allowed height H satisfies H ≤ tan(32°) × (W/2) ≈ 0.625 × W/2. + // We verify by checking that elevation differences between adjacent tiles + // don't exceed the physics cap. + let region = dune_region(); + let chunk = derive_chunk_context(42, "dune_body", ®ion, (0, 0)); + // Sample a row of tiles along the dune wave direction. + let elevations: Vec = (0..40i32) + .map(|y| derive_voxel_column(42, "dune_body", ®ion, &chunk, 10, y).elevation_m) + .collect(); + // Max elevation in the sample. + let max_elev = *elevations.iter().max().unwrap(); + // Dune wavelength for slope_q=8: 20 - 8/2 = 16 m. Physics cap per wave = 0.625 × 8 = 5 m. + // Allow a margin of 2 for scatter noise. + let physics_cap = 8; // generous upper bound including noise + assert!( + max_elev <= physics_cap, + "DuneStrand max elevation {} exceeds angle-of-repose physics cap {}", + max_elev, + physics_cap + ); + } + + #[test] + fn dune_strand_mostly_dry() { + // Dunes are mostly dry; only the strand toe may be Shallow. + let region = dune_region(); + let chunk = derive_chunk_context(42, "dune_body", ®ion, (0, 0)); + let dry_or_shallow_count = (0..100i32) + .filter(|&x| { + let col = derive_voxel_column(42, "dune_body", ®ion, &chunk, x, 50); + col.water != Water::Deep // no Deep water on dunes + }) + .count(); + assert_eq!( + dry_or_shallow_count, 100, + "DuneStrand must have no Deep water" + ); + } + + #[test] + fn dune_strand_is_deterministic() { + let region = dune_region(); + let chunk = derive_chunk_context(3, "dune_body", ®ion, (1, 0)); + for (tx, ty) in [(10, 10), (0, 30), (-5, 7)] { + let a = derive_voxel_column(3, "dune_body", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(3, "dune_body", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "DuneStrand must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn dune_strand_elevation_non_negative() { + let region = dune_region(); + let chunk = derive_chunk_context(42, "dune_body", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "dune_body", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "DuneStrand elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — IncisedGorge + // ----------------------------------------------------------------------- + + fn gorge_region() -> RegionProfile { + RegionProfile { morphology_zone: MorphologyZone::MountainPass, + tectonic_class: TectonicClass::Active, + slope_q: 80, + elev_q: 90, + ocean_fraction_q: 5, + moisture_q: 50, + vegetation_class: VegetationClass::Barren, ..alluvial_region() - }; - let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); - let col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 10, 10); - assert_eq!(col.terrain, TerrainMaterial::Rock); + } } #[test] - fn meander_reach_family_stub_does_not_panic() { + fn incised_gorge_terrain_is_rock() { + let region = gorge_region(); + let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); + for (tx, ty) in [(10, 10), (0, 0), (50, 25), (30, -5)] { + let col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty); + assert_eq!( + col.terrain, + TerrainMaterial::Rock, + "IncisedGorge must produce Rock terrain at ({tx},{ty})" + ); + } + } + + #[test] + fn incised_gorge_floor_width_in_spec() { + // D-239 §9: gorge floors 2–8 m wide. We verify by checking that the + // total width of the Shallow water zone (the incised channel on the floor) + // across a cross-section is within [2, 8] m. + let region = gorge_region(); + let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); + // Guard: this sweep assumes N/S basin (cross axis = tile_x). If the seed + // yields E/W the sweep would be along the gorge, not across it. + assert!( + matches!( + chunk.basin_direction, + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South + ), + "test assumes N/S basin direction; pick a different seed if this fires" + ); + // Sample 64 cross-positions at a fixed along-axis position. + // For North/South basin direction: cross = tile_x. + // The floor should be ≤ 8 tiles (= 8 m) wide. + let floor_tiles = (0..64i32) + .filter(|&x| { + derive_voxel_column(42, "gorge_body", ®ion, &chunk, x, 100).water + == Water::Shallow + }) + .count(); + assert!( + (2..=8).contains(&floor_tiles), + "IncisedGorge floor width {floor_tiles} m outside [2, 8] m spec (D-239 §9)" + ); + } + + #[test] + fn incised_gorge_walls_much_higher_than_floor() { + // Walls must be substantially higher than the gorge floor. + let region = gorge_region(); + let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); + // Floor: tile at cross_from_centre = 0, i.e. tile_x = 32. + let floor_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 32, 100); + // Wall: tile at cross_from_centre = 30, i.e. tile_x = 62 or tile_x = 2. + let wall_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 62, 100); + assert!( + wall_col.elevation_m > floor_col.elevation_m + 10, + "IncisedGorge walls ({}) must be >> gorge floor ({})", + wall_col.elevation_m, + floor_col.elevation_m + ); + } + + #[test] + fn incised_gorge_is_deterministic() { + let region = gorge_region(); + let chunk = derive_chunk_context(17, "gorge_body", ®ion, (4, 2)); + for (tx, ty) in [(32, 100), (62, 100), (10, 50)] { + let a = derive_voxel_column(17, "gorge_body", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(17, "gorge_body", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "IncisedGorge must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn incised_gorge_elevation_non_negative() { + let region = gorge_region(); + let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "IncisedGorge elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — MeanderReach + // ----------------------------------------------------------------------- + + fn meander_region() -> RegionProfile { + RegionProfile { + morphology_zone: MorphologyZone::MeanderReach, + slope_q: 3, + elev_q: 15, + ocean_fraction_q: 20, + moisture_q: 70, + vegetation_class: VegetationClass::Forest, + ..alluvial_region() + } + } + + #[test] + fn meander_reach_terrain_is_soil_or_wetland() { + // MeanderReach terrain is Soil (or Wetland sub-zone on very flat/wet ground). + // D-239 §8 Soil law: rolling/floodplain. Wetland is a valid sub-classification + // for saturated floodplains (slope_q ≤ 3, moisture_q ≥ 70 in the meander_region). + // This test checks that NO other material appears (no Rock, Sand, Gravel, Lava). + let region = meander_region(); + let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0)); + for (tx, ty) in [ + (0, 50), + (5, 50), + (10, 50), + (15, 50), + (100, 50), + (200, 50), + (300, 50), + (400, 50), + ] { + let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty); + assert!( + matches!( + col.terrain, + TerrainMaterial::Soil | TerrainMaterial::Wetland + ), + "MeanderReach terrain must be Soil or Wetland, got {:?} at ({tx},{ty})", + col.terrain + ); + } + } + + #[test] + fn meander_reach_non_wetland_region_produces_soil() { + // A less-wet MeanderReach region (moisture_q < 70) should produce Soil, not Wetland. let region = RegionProfile { morphology_zone: MorphologyZone::MeanderReach, + slope_q: 5, // above wetland slope threshold + elev_q: 15, + ocean_fraction_q: 20, + moisture_q: 55, // below wetland moisture threshold + vegetation_class: VegetationClass::Forest, ..alluvial_region() }; + let chunk = derive_chunk_context(42, "meander_body2", ®ion, (0, 0)); + let mut soil_count = 0; + for tx in [0, 5, 10, 15, 100, 200] { + let col = derive_voxel_column(42, "meander_body2", ®ion, &chunk, tx, 50); + if col.terrain == TerrainMaterial::Soil { + soil_count += 1; + } + } + assert!( + soil_count >= 5, + "Non-wetland MeanderReach must be mostly Soil, got {soil_count}/6" + ); + } + + #[test] + fn meander_reach_has_active_channel() { + // MeanderReach must have a channel with Shallow/Deep water somewhere in a transect. + let region = meander_region(); let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0)); - let _col = derive_voxel_column(42, "meander_body", ®ion, &chunk, 10, 10); - // No panic = pass. + // The chunk has_active_channel = true (ocean_fraction_q=20 >= 10). + assert!( + chunk.has_active_channel, + "MeanderReach test chunk must have active channel" + ); + // Sample a wide cross-section and find at least one wet tile. + let wet_count = (-200..200i32) + .filter(|&x| { + let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, x, 50); + col.water != Water::Dry + }) + .count(); + assert!( + wet_count >= 3, + "MeanderReach must have a visible channel with wet tiles in cross-section; got {wet_count}" + ); + } + + #[test] + fn meander_reach_channel_below_levee() { + // D-239 §9 ElevationDelta: channel tiles must have lower elevation than + // off-channel tiles in the vicinity. + let region = meander_region(); + let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0)); + // Find a channel tile and a floodplain tile at the same along-axis position. + let mut channel_elev: Option = None; + let mut plain_elev: Option = None; + for tx in -200..200i32 { + let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, 50); + if col.water != Water::Dry && channel_elev.is_none() { + channel_elev = Some(col.elevation_m); + } + if col.water == Water::Dry { + plain_elev = Some(col.elevation_m); + } + if channel_elev.is_some() && plain_elev.is_some() { + break; + } + } + if let (Some(ch_elev), Some(pl_elev)) = (channel_elev, plain_elev) { + assert!( + ch_elev <= pl_elev, + "MeanderReach: channel elevation ({ch_elev}) must be <= floodplain ({pl_elev})" + ); + } + } + + #[test] + fn meander_reach_stronger_sinuosity_than_alluvial() { + // MeanderReach uses channel amplitude = wavelength/4 vs AlluvialPlain's + // wavelength/6. Same region params, same seed, same body — the ONLY + // difference is the morphology_zone driving the dispatch. Different + // amplitudes shift the channel centreline position differently across the + // chunk, so the set of wet-tile x-positions must differ. If MeanderReach + // ever regresses to AlluvialPlain's amplitude (or falls back to the same + // code path), the wet-tile sets become identical and this test fails. + // + // We use the SAME elev_q, slope_q, ocean_fraction_q, and seed for both — + // so the only driver of the difference is the sinuosity amplitude. + let shared_region_base = RegionProfile { + slope_q: 5, + elev_q: 20, + ocean_fraction_q: 20, // ensure has_active_channel = true + moisture_q: 55, + vegetation_class: VegetationClass::Forest, + ..alluvial_region() + }; + let meander_reg = RegionProfile { + morphology_zone: MorphologyZone::MeanderReach, + ..shared_region_base.clone() + }; + let alluvial_reg = RegionProfile { + morphology_zone: MorphologyZone::AlluvialPlain, + ..shared_region_base + }; + // Same seed + body → same ChunkContext (same wavelength, phase, channel_width). + let meander_chunk = derive_chunk_context(42, "sinuosity_body", &meander_reg, (0, 0)); + let alluvial_chunk = derive_chunk_context(42, "sinuosity_body", &alluvial_reg, (0, 0)); + // Verify the chunks share the same meander params (confirming the test setup). + assert_eq!( + meander_chunk.meander_phase, alluvial_chunk.meander_phase, + "test setup requires identical chunk params" + ); + assert!( + meander_chunk.has_active_channel && alluvial_chunk.has_active_channel, + "both chunks must have active channels for the test to be meaningful" + ); + // Collect wet-tile x-positions over one full wavelength transect (at y=50). + // We scan far enough to capture the full meander swing — amplitude for + // MeanderReach is wl/4, for AlluvialPlain wl/6, so over a [−400,+400] sweep + // both complete multiple full cycles and their centreline positions diverge. + let meander_wet: Vec = (-400..400i32) + .filter(|&x| { + derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, x, 50).water + != Water::Dry + }) + .collect(); + let alluvial_wet: Vec = (-400..400i32) + .filter(|&x| { + derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, x, 50) + .water + != Water::Dry + }) + .collect(); + assert!( + !meander_wet.is_empty(), + "MeanderReach must have a wet channel" + ); + assert!( + !alluvial_wet.is_empty(), + "AlluvialPlain must have a wet channel" + ); + // The wet-tile position sets must differ — proving MeanderReach uses a + // distinct sinuosity from AlluvialPlain. If this fails, the two generators + // produce identical channel positions, meaning MeanderReach has regressed. + assert_ne!( + meander_wet, alluvial_wet, + "MeanderReach and AlluvialPlain must produce different wet-tile positions \ + (different channel amplitude = different sinuosity); they were identical, \ + which means MeanderReach has regressed to AlluvialPlain amplitude" + ); + } + + #[test] + fn meander_reach_is_deterministic() { + let region = meander_region(); + let chunk = derive_chunk_context(23, "meander_body", ®ion, (2, 5)); + for (tx, ty) in [(0, 50), (50, 200), (-20, 100)] { + let a = derive_voxel_column(23, "meander_body", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(23, "meander_body", ®ion, &chunk, tx, ty); + assert_eq!(a, b, "MeanderReach must be deterministic at ({tx},{ty})"); + } + } + + #[test] + fn meander_reach_elevation_non_negative() { + let region = meander_region(); + let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0)); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty); + assert!(col.elevation_m >= 0, "MeanderReach elevation must be >= 0"); + } + } + + // ----------------------------------------------------------------------- + // T-1029 — Cross-family: no panics, correct materials, all deterministic + // ----------------------------------------------------------------------- + + /// All 7 T-1029 families must not panic on any sample position. + #[test] + fn all_families_no_panic_at_extreme_positions() { + let zones = [ + MorphologyZone::Volcanic, + MorphologyZone::Fjord, + MorphologyZone::CliffCoast, + MorphologyZone::Delta, + MorphologyZone::DuneStrand, + MorphologyZone::MountainPass, + MorphologyZone::MeanderReach, + ]; + let positions = [ + (0, 0), + (i32::MAX / 4, 0), + (0, i32::MAX / 4), + (-100, -100), + (1000, 2000), + (-500, 300), + ]; + for zone in &zones { + let region = RegionProfile { + morphology_zone: zone.clone(), + tectonic_class: TectonicClass::Volcanic, + glaciation_grade: GlaciationGrade::Moderate, + slope_q: 50, + elev_q: 40, + ocean_fraction_q: 20, + moisture_q: 50, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "stress_body", ®ion, (0, 0)); + for (tx, ty) in &positions { + // Must not panic. + let col = derive_voxel_column(42, "stress_body", ®ion, &chunk, *tx, *ty); + assert!( + col.elevation_m >= 0, + "elevation must be non-negative for {:?} at ({tx},{ty})", + zone + ); + } + } + } + + /// Each T-1029 family produces the expected TerrainMaterial (D-239 §8 lithology law). + #[test] + fn family_terrain_materials_match_lithology_law() { + let cases: &[(MorphologyZone, TerrainMaterial)] = &[ + (MorphologyZone::Volcanic, TerrainMaterial::Lava), + (MorphologyZone::Fjord, TerrainMaterial::Rock), + (MorphologyZone::CliffCoast, TerrainMaterial::Rock), + (MorphologyZone::Delta, TerrainMaterial::Gravel), + (MorphologyZone::DuneStrand, TerrainMaterial::Sand), + (MorphologyZone::MountainPass, TerrainMaterial::Rock), + ]; + for (zone, expected_terrain) in cases { + let region = RegionProfile { + morphology_zone: zone.clone(), + tectonic_class: TectonicClass::Volcanic, + glaciation_grade: GlaciationGrade::Moderate, + slope_q: 50, + elev_q: 40, + ocean_fraction_q: 5, + moisture_q: 30, + vegetation_class: VegetationClass::Barren, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "mat_check", ®ion, (0, 0)); + let col = derive_voxel_column(42, "mat_check", ®ion, &chunk, 10, 10); + assert_eq!( + col.terrain, *expected_terrain, + "Zone {:?} must produce {:?} terrain", + zone, expected_terrain + ); + } } }