fix(simulation): region-anchor channels and linear landforms (T-1040, T-1041)
derive_chunk_context now derives channel_anchor_m and coast_anchor_m from the Region-or-higher seed path (D-239 §10); all five voxel placement sites measure distance to these anchors in continuous world coordinates: - T-1040: compute_channel_state / compute_meander_reach_channel / in_levee_band subtract the region anchor instead of measuring from the world origin axis — active channels now exist in their chunks anywhere on the body, continuous across chunk boundaries. has_active_channel is honest in both directions (band-crossing gate; floor covers the BraidedDelta belt at short wavelengths). - T-1041: fjord/cliff/gorge/delta drop their rem_euclid(64) chunk-frame folds — one valley/coastline/fan per region instead of one per chunk. D-239 §9 chokepoint widths preserved; two i32 wall-rise overflows on region-scale distances fixed by clamping before multiply. Harness: 6 new believability tests (channel presence + gate honesty at chunk (1000,-750), cross-boundary continuity, one-landform-per-region, braid-belt confinement); drainage sweeps strengthened with anchor-band chunks. Voxel golden regenerated deliberately: case A now pins a genuinely in-channel Shallow voxel on the anchor, case C a Deep trough voxel matching its label; case B byte-identical and L0/L1 cascade golden untouched (no upstream leak). Review: approved, three minors addressed (truncation-convention docs, delta gate floor, case C relocation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,11 +18,20 @@
|
||||
//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure:
|
||||
//! the chunk seed is only used for sub-chunk (<64 m) scatter.
|
||||
//!
|
||||
//! The same rule places the feature axes themselves (T-1040/T-1041): the channel
|
||||
//! centreline and landform axis (`channel_anchor_m`) and the coast-face line
|
||||
//! (`coast_anchor_m`) are **world-metre coordinates derived once per region**
|
||||
//! from the region-scale seed. Voxel generators measure distance to these
|
||||
//! anchors in continuous world coordinates — never from the world origin and
|
||||
//! never folded into the 64 m chunk frame.
|
||||
//!
|
||||
//! ## D-010 compliance
|
||||
//!
|
||||
//! All derivation is integer arithmetic. The only f64 in this module is
|
||||
//! `meander_wavelength_m` (positional physics, not a gate comparison), consistent
|
||||
//! with D-239 §4 ("the warp is position math, not a structural decision").
|
||||
//! `meander_wavelength_m` (positional physics, D-239 §4). Structural decisions
|
||||
//! consume it only via deterministic i32 truncation (the `has_active_channel`
|
||||
//! band reach), mirroring the established voxel.rs truncate-before-decision
|
||||
//! convention — only correctly-rounded f64 +,-,*,/ feed the truncation.
|
||||
//!
|
||||
//! ## SeedDomain extension
|
||||
//!
|
||||
@@ -66,6 +75,32 @@ pub enum BasinDirection {
|
||||
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
|
||||
pub type ChunkPos = (i32, i32);
|
||||
|
||||
/// Chunk edge length in metres.
|
||||
pub const CHUNK_M: i32 = 64;
|
||||
|
||||
/// Region edge length in chunks (region ≈ 1 km = 16 chunks). Must match the
|
||||
/// `>> REGION_CHUNKS_SHIFT` region-index mapping used for the region-scale seed.
|
||||
const REGION_CHUNKS_SHIFT: u32 = 4;
|
||||
|
||||
/// Region edge length in metres (1 024 m).
|
||||
const REGION_M: i32 = CHUNK_M << REGION_CHUNKS_SHIFT;
|
||||
|
||||
/// Margin keeping a region's feature anchor away from the region edge, so the
|
||||
/// channel's full swept band (max meander amplitude wavelength/4 ≈ 162 m +
|
||||
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the region.
|
||||
/// Cross-region feature continuity is the stage-2 Voronoi model (T-1040).
|
||||
const ANCHOR_MARGIN_M: i32 = 192;
|
||||
|
||||
/// Seed-addressable anchor span within a region (REGION_M − 2 × margin).
|
||||
const ANCHOR_SPAN_M: i32 = REGION_M - 2 * ANCHOR_MARGIN_M;
|
||||
|
||||
/// Maximum levee band width in metres (`voxel::in_levee_band`: 4 + 3 jitter).
|
||||
const LEVEE_BAND_MAX_M: i32 = 7;
|
||||
|
||||
/// Domain-warp displacement bound in metres (mirrors `domain_warp::WARP_BOUND`,
|
||||
/// D-239 §4 ±8 m). Integer here — used only to widen the channel gate band.
|
||||
const WARP_BOUND_M: i32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChunkContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,11 +117,15 @@ pub type ChunkPos = (i32, i32);
|
||||
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
|
||||
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
|
||||
/// region-level morphology (slope, moisture), seeded at region scale (> 64 m).
|
||||
/// f64 for positional physics (D-239 §4); not used in any gate comparison.
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk,
|
||||
/// derived from the region's `ocean_fraction_q` and `river_threshold`.
|
||||
/// f64 for positional physics (D-239 §4); structural decisions consume it
|
||||
/// only via deterministic i32 truncation (the `has_active_channel` band).
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk:
|
||||
/// the region has water presence AND the channel's swept band around
|
||||
/// `channel_anchor_m` crosses this chunk (T-1040).
|
||||
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
|
||||
/// active channel.
|
||||
/// - `channel_anchor_m` / `coast_anchor_m` — region-anchored feature axes in
|
||||
/// world metres (T-1040/T-1041, D-239 §10).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChunkContext {
|
||||
/// Dominant basin-flow direction for this chunk's drainage catchment.
|
||||
@@ -101,13 +140,28 @@ pub struct ChunkContext {
|
||||
pub meander_wavelength_m: f64,
|
||||
|
||||
/// Whether this chunk contains an active water channel.
|
||||
/// True when the region's `ocean_fraction_q` × `river_threshold` signal
|
||||
/// indicates a perennial waterway crosses this chunk.
|
||||
/// True when the region has water presence (`ocean_fraction_q` ≥ 10) AND
|
||||
/// the channel's swept band around `channel_anchor_m` crosses this chunk's
|
||||
/// cross-axis range (T-1040 — channels exist where the region says, not
|
||||
/// region-wide and not only at the world origin).
|
||||
pub has_active_channel: bool,
|
||||
|
||||
/// Active channel width in metres (integer; D-010). 0 if no active channel.
|
||||
/// Derived from region morphology and slope.
|
||||
pub channel_width_m: i32,
|
||||
|
||||
/// Cross-axis world-metre coordinate of the region's feature centreline:
|
||||
/// channel/meander axis, fjord trough, gorge floor, braid-fan axis.
|
||||
/// Derived once per region from the region-scale seed (T-1040/T-1041,
|
||||
/// D-239 §10) — constant across all chunks of a region, so the feature is
|
||||
/// continuous across chunk boundaries. Cross axis = x for N/S basins,
|
||||
/// y for E/W basins.
|
||||
pub channel_anchor_m: i32,
|
||||
|
||||
/// Along-axis (basin-axis) world-metre coordinate of the CliffCoast face
|
||||
/// line. Region-scale (T-1041): one continuous coast per region, not a
|
||||
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
|
||||
pub coast_anchor_m: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -155,21 +209,44 @@ pub fn derive_chunk_context(
|
||||
// is positional physics (D-239 §4, not a gate comparison).
|
||||
let meander_wavelength_m = derive_meander_wavelength(region);
|
||||
|
||||
// Active channel — present when the region has meaningful water presence
|
||||
// (ocean_fraction_q >= 10 indicates a perennial waterway or water body
|
||||
// covers at least 10% of the region). The river_threshold governs D8
|
||||
// drainage accumulation at a finer scale; at the chunk level we use
|
||||
// ocean_fraction_q as the direct proxy for water presence (D-239 §10:
|
||||
// the chunk carries one basin-direction, not a per-tile flow grid).
|
||||
let has_active_channel = region.ocean_fraction_q >= 10;
|
||||
|
||||
// Channel width — derived from ocean_fraction_q (proxy for water presence
|
||||
// at region scale); integer metres; 0 when no active channel.
|
||||
let channel_width_m = if has_active_channel {
|
||||
derive_channel_width(region)
|
||||
} else {
|
||||
0
|
||||
// Region-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
|
||||
// landform centrelines have wavelength > 64 m, so their position derives
|
||||
// from the region-scale seed — never the chunk frame or the world origin.
|
||||
// Cross axis ⊥ basin_direction (x for N/S, y for E/W); along axis ∥ basin.
|
||||
let (cross_chunk, along_chunk) = match basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (chunk_pos.0, chunk_pos.1),
|
||||
BasinDirection::East | BasinDirection::West => (chunk_pos.1, chunk_pos.0),
|
||||
};
|
||||
let channel_anchor_m = derive_region_anchor(cross_chunk, (region_seed.seed() >> 16) & 0xFFFF);
|
||||
let coast_anchor_m = derive_region_anchor(along_chunk, (region_seed.seed() >> 32) & 0xFFFF);
|
||||
|
||||
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
|
||||
// perennial waterway or water body covers at least 10% of the region) AND
|
||||
// the channel's swept band around the region anchor crosses this chunk
|
||||
// (T-1040). The river_threshold governs D8 drainage accumulation at a finer
|
||||
// scale; at the chunk level ocean_fraction_q is the direct proxy for water
|
||||
// presence (D-239 §10: one basin-direction, not a per-tile flow grid).
|
||||
//
|
||||
// The band is generous (it must cover every chunk that can contain channel,
|
||||
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
|
||||
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
|
||||
let channel_width = derive_channel_width(region);
|
||||
let has_active_channel = region.ocean_fraction_q >= 10 && {
|
||||
let wavelength_i = (meander_wavelength_m as i32).max(10);
|
||||
let amplitude_max = (wavelength_i / 4).max(3);
|
||||
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
|
||||
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
|
||||
// thread-half) before warp regardless of wavelength — the band must
|
||||
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
|
||||
// gate generosity for the other families).
|
||||
let reach = (amplitude_max + edge_max + LEVEE_BAND_MAX_M).max(32 + 4) + WARP_BOUND_M;
|
||||
let cross_lo = cross_chunk * CHUNK_M;
|
||||
let cross_hi = cross_lo + CHUNK_M - 1;
|
||||
cross_lo <= channel_anchor_m + reach && cross_hi >= channel_anchor_m - reach
|
||||
};
|
||||
|
||||
// Channel width — integer metres; 0 when no active channel in this chunk.
|
||||
let channel_width_m = if has_active_channel { channel_width } else { 0 };
|
||||
|
||||
ChunkContext {
|
||||
basin_direction,
|
||||
@@ -177,9 +254,26 @@ pub fn derive_chunk_context(
|
||||
meander_wavelength_m,
|
||||
has_active_channel,
|
||||
channel_width_m,
|
||||
channel_anchor_m,
|
||||
coast_anchor_m,
|
||||
}
|
||||
}
|
||||
|
||||
/// World-metre anchor coordinate for a region-scale feature axis on one axis.
|
||||
///
|
||||
/// The anchor sits in `[region_origin + ANCHOR_MARGIN_M, region_origin +
|
||||
/// REGION_M − ANCHOR_MARGIN_M)` so the feature's full swept band stays inside
|
||||
/// its region (no cross-region band spill; region-seam continuity is the
|
||||
/// stage-2 Voronoi model, T-1040). Same value for every chunk of the region:
|
||||
/// the region index is `axis_chunk >> REGION_CHUNKS_SHIFT` (arithmetic shift =
|
||||
/// floor division, correct for negative chunks) and `seed_bits` comes from the
|
||||
/// shared region-scale seed. Integer arithmetic (D-010).
|
||||
fn derive_region_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
|
||||
let region_idx = axis_chunk >> REGION_CHUNKS_SHIFT;
|
||||
let origin_m = region_idx * REGION_M;
|
||||
origin_m + ANCHOR_MARGIN_M + (seed_bits % ANCHOR_SPAN_M as u64) as i32
|
||||
}
|
||||
|
||||
/// Fold `(x, y)` chunk coordinates into a single u64 id for seed derivation.
|
||||
///
|
||||
/// Mirrors `domain_warp::pos_to_id` — zigzag-encode + Cantor pairing.
|
||||
@@ -294,6 +388,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk of region (0, 0) whose cross-range contains the region's
|
||||
/// channel anchor — guaranteed inside the T-1040 channel band.
|
||||
fn anchor_chunk_pos(world_seed: u64, body_id: &str, region: &RegionProfile) -> ChunkPos {
|
||||
let probe = derive_chunk_context(world_seed, body_id, region, (0, 0));
|
||||
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
|
||||
match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_chunk_context_is_deterministic() {
|
||||
let region = alluvial_region();
|
||||
@@ -321,14 +426,80 @@ mod tests {
|
||||
#[test]
|
||||
fn alluvial_region_has_active_channel() {
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
// ocean_fraction_q=15 + temperate precip → should have active channel.
|
||||
// T-1040: the channel is region-anchored — the chunk under the anchor
|
||||
// must claim it (ocean_fraction_q=15 → water present).
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
ctx.has_active_channel,
|
||||
"alluvial plain with ocean_fraction_q=15 should have active channel"
|
||||
"anchor-covering chunk of a watered region must have active channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_outside_channel_band_has_no_active_channel() {
|
||||
// T-1040: channels are region-anchored, not region-wide. A chunk whose
|
||||
// cross-range lies outside the channel's swept band (max reach < 192 m
|
||||
// = 3 chunks) must not claim a channel — pre-fix every chunk of a
|
||||
// watered region did, while voxels rendered dry floodplain.
|
||||
let region = alluvial_region();
|
||||
let (anchor_pos, probe) = {
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
(pos, derive_chunk_context(42, "GJ1c", ®ion, pos))
|
||||
};
|
||||
// 8 cross-chunks away (512 m) is past any band reach but still inside
|
||||
// region (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
|
||||
let anchor_idx = anchor_pos.0.max(anchor_pos.1);
|
||||
let far_idx = if anchor_idx < 8 {
|
||||
anchor_idx + 8
|
||||
} else {
|
||||
anchor_idx - 8
|
||||
};
|
||||
let far_pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (far_idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, far_idx),
|
||||
};
|
||||
let far_ctx = derive_chunk_context(42, "GJ1c", ®ion, far_pos);
|
||||
assert!(
|
||||
!far_ctx.has_active_channel,
|
||||
"chunk {far_pos:?} outside the channel band must not claim a channel"
|
||||
);
|
||||
assert_eq!(
|
||||
far_ctx.channel_width_m, 0,
|
||||
"no active channel → channel_width_m must be 0"
|
||||
);
|
||||
// Region-scale params stay constant across the region's chunks.
|
||||
assert_eq!(far_ctx.channel_anchor_m, probe.channel_anchor_m);
|
||||
assert_eq!(far_ctx.meander_phase, probe.meander_phase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_constant_within_region_and_inside_it() {
|
||||
// T-1040/T-1041: feature anchors are a region property — identical for
|
||||
// every chunk of the region, and positioned inside the region's extent.
|
||||
let region = alluvial_region();
|
||||
let base = derive_chunk_context(42, "GJ1c", ®ion, (0, 0));
|
||||
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert_eq!(
|
||||
ctx.channel_anchor_m, base.channel_anchor_m,
|
||||
"channel anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.coast_anchor_m, base.coast_anchor_m,
|
||||
"coast anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
}
|
||||
// Region (0, 0) spans [0, 1024) m on both axes.
|
||||
assert!((0..1024).contains(&base.channel_anchor_m));
|
||||
assert!((0..1024).contains(&base.coast_anchor_m));
|
||||
// A different region derives its anchors inside its own extent.
|
||||
let far = derive_chunk_context(42, "GJ1c", ®ion, (1000, -750));
|
||||
assert!((62 * 1024..63 * 1024).contains(&far.channel_anchor_m) || (-47 * 1024..-46 * 1024).contains(&far.channel_anchor_m),
|
||||
"far region anchor {} must lie inside its region extent (cross axis depends on basin direction)",
|
||||
far.channel_anchor_m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_region_has_no_active_channel() {
|
||||
let region = RegionProfile {
|
||||
@@ -353,9 +524,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn channel_width_in_game_feel_range() {
|
||||
// D-239 §9: river crossings 3–15 m.
|
||||
// D-239 §9: river crossings 3–15 m. Measured on a chunk that carries
|
||||
// the channel (T-1040 gating zeroes the width elsewhere).
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
(3..=15).contains(&ctx.channel_width_m),
|
||||
"channel_width_m {} out of game-feel range [3, 15]",
|
||||
|
||||
+242
-205
@@ -413,10 +413,11 @@ pub fn derive_voxel_column(
|
||||
///
|
||||
/// ## Meander channel placement
|
||||
///
|
||||
/// The channel is placed by a sine-wave approximation centred on the chunk's
|
||||
/// `basin_direction`. The perpendicular distance from the voxel to the wave
|
||||
/// determines whether the voxel is in-channel. This avoids per-tile flow
|
||||
/// grids while producing a spatially coherent channel (D-239 §10).
|
||||
/// The channel is a sine-wave approximation running along `basin_direction`,
|
||||
/// centred on the region-anchored `channel_anchor_m` (T-1040). The
|
||||
/// perpendicular distance from the voxel to the wave determines whether the
|
||||
/// voxel is in-channel. This avoids per-tile flow grids while producing a
|
||||
/// spatially coherent channel continuous across chunk boundaries (D-239 §10).
|
||||
///
|
||||
/// Channel width is `chunk.channel_width_m` (game-feel range 3–15 m).
|
||||
fn generate_alluvial_plain(
|
||||
@@ -570,8 +571,9 @@ fn generate_lava_field(
|
||||
/// - 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.
|
||||
/// The fjord trough runs along `basin_direction` at the region-anchored
|
||||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE valley spans
|
||||
/// the region's chunks. Cross-section: wall | moraine | Deep water | moraine | wall.
|
||||
fn generate_fjord_wall(
|
||||
region: &RegionProfile,
|
||||
chunk: &ChunkContext,
|
||||
@@ -580,8 +582,7 @@ fn generate_fjord_wall(
|
||||
) -> 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).
|
||||
// the centre-line.
|
||||
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),
|
||||
@@ -589,12 +590,10 @@ fn generate_fjord_wall(
|
||||
| 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
|
||||
};
|
||||
// Cross-channel distance from the region-anchored trough centreline
|
||||
// (T-1041): continuous world coordinates — folding into the 64 m chunk
|
||||
// frame repeated a complete fjord in every chunk (D-239 §10 violation).
|
||||
let cross_from_centre = (cross - chunk.channel_anchor_m).abs();
|
||||
|
||||
// ── U-valley geometry ─────────────────────────────────────────────────
|
||||
// The fjord bottom is 4–8 m wide (chokepoint, D-239 §9 "gorge floors 2–8 m").
|
||||
@@ -646,8 +645,11 @@ fn generate_fjord_wall(
|
||||
// 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;
|
||||
// Steep linear rise: 6 m per metre of cross-distance (approximates
|
||||
// vertical), saturating at a 120 m plateau 20 m out — cross distance
|
||||
// is region-scale post-T-1041, so the rise must not grow unbounded to
|
||||
// the region edge (clamp before multiplying: i32 overflow otherwise).
|
||||
let wall_rise = wall_dist.min(20) * 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 {
|
||||
@@ -696,8 +698,10 @@ fn generate_fjord_wall(
|
||||
/// 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.
|
||||
/// - The coast face sits on the region-anchored line `chunk.coast_anchor_m`
|
||||
/// along the seaward axis (T-1041, D-239 §10): ONE continuous coast line
|
||||
/// per region (warp-displaced), not a 64 m sawtooth. Inland of the line is
|
||||
/// high ground; seaward is ocean.
|
||||
/// - 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(
|
||||
@@ -707,25 +711,21 @@ fn generate_cliff_coast(
|
||||
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,
|
||||
// basin_direction points seaward (water flows to ocean). coast_d is the
|
||||
// signed seaward distance from the region-anchored coast line in
|
||||
// continuous world metres (T-1041): negative = inland, positive = seaward.
|
||||
let coast_d = match chunk.basin_direction {
|
||||
crate::atlas::chunk_context::BasinDirection::North => chunk.coast_anchor_m - voxel_pos.1,
|
||||
crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1 - chunk.coast_anchor_m,
|
||||
crate::atlas::chunk_context::BasinDirection::East => voxel_pos.0 - chunk.coast_anchor_m,
|
||||
crate::atlas::chunk_context::BasinDirection::West => chunk.coast_anchor_m - 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.
|
||||
// Inland (coast_d < 0): high ground, rocky top.
|
||||
// Face (0..8): steep drop (>6 m per metre — near-vertical, D-239 §8).
|
||||
// Ledge (8..13): rocky beach platform, Shallow.
|
||||
// Ocean (≥13): Deep water.
|
||||
//
|
||||
// Cliff height derived from region elevation.
|
||||
let cliff_top_elev = (region.elev_q / 2).max(15); // at least 15 m cliff
|
||||
@@ -735,18 +735,18 @@ fn generate_cliff_coast(
|
||||
// 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 {
|
||||
let (elevation_m, water) = if coast_d < 0 {
|
||||
// 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 {
|
||||
} else if coast_d < 8 {
|
||||
// Cliff face — steep drop. Each metre seaward drops ~cliff_top/8 metres.
|
||||
let face_dist = seaward_norm - 48; // 0–7
|
||||
let face_dist = coast_d; // 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 {
|
||||
} else if coast_d < 13 {
|
||||
// Rocky ledge / splash zone.
|
||||
let elev = (1 + ledge_noise).max(0);
|
||||
(elev, Water::Shallow)
|
||||
@@ -755,17 +755,19 @@ fn generate_cliff_coast(
|
||||
(0, Water::Deep)
|
||||
};
|
||||
|
||||
// Vegetation: nearly barren on rock faces; tiny scatter of Scrub on inland top.
|
||||
// Vegetation: nearly barren on rock faces; tiny scatter of Scrub on the
|
||||
// inland top (≥18 m / ≥28 m inland of the face — same offsets as the old
|
||||
// chunk-frame layout, now measured from the region-anchored coast line).
|
||||
let vegetation = match region.vegetation_class {
|
||||
VegetationClass::Forest | VegetationClass::RiparianThicket => {
|
||||
if seaward_norm < 30 && (sub_chunk_seed >> 10) & 0x7 < 3 {
|
||||
if coast_d < -18 && (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 {
|
||||
if coast_d < -28 && (sub_chunk_seed >> 10) & 0x3 == 0 {
|
||||
Vegetation::Scrub
|
||||
} else {
|
||||
Vegetation::Barren
|
||||
@@ -795,9 +797,12 @@ fn generate_cliff_coast(
|
||||
/// - 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).
|
||||
/// - Channel determination: 3 independent braided threads anastomosing
|
||||
/// across a fan belt centred on the region-anchored axis
|
||||
/// `chunk.channel_anchor_m` (T-1041, D-239 §10), each thread 3–8 m wide.
|
||||
/// Thread offsets are seeded at region scale (meander_phase encodes them),
|
||||
/// so the threads run continuously across the region's chunks instead of
|
||||
/// restarting every 64 m.
|
||||
/// - `Shallow` in-channel; `Dry` on gravel bars between threads.
|
||||
fn generate_braided_delta(
|
||||
region: &RegionProfile,
|
||||
@@ -821,11 +826,9 @@ fn generate_braided_delta(
|
||||
| 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).
|
||||
// Three braided threads with well-separated centres derived from meander_phase,
|
||||
// spread across a 64 m fan belt centred on the region-anchored axis
|
||||
// (T-1041 — world cross coordinates, constant for all chunks of the region).
|
||||
// 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;
|
||||
@@ -851,21 +854,19 @@ fn generate_braided_delta(
|
||||
h ^ (h >> 31)
|
||||
};
|
||||
let thread_centres: [i32; 3] = [
|
||||
(mix_a % 64) as i32,
|
||||
(mix_b % 64) as i32,
|
||||
(mix_c % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (mix_a % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (mix_b % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (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
|
||||
});
|
||||
// Check if voxel is in any braided thread — continuous world-coordinate
|
||||
// distance (the old [0, 64) ring fold restarted the threads every chunk).
|
||||
let in_any_thread = thread_centres
|
||||
.iter()
|
||||
.any(|¢re| (cross - centre).abs() <= thread_half);
|
||||
|
||||
let (elevation_m, water) = if in_any_thread {
|
||||
// In a distributary channel — at or just below base elevation.
|
||||
@@ -1036,6 +1037,9 @@ fn generate_dune_strand(
|
||||
/// - 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.
|
||||
/// - The gorge runs along `basin_direction` at the region-anchored
|
||||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE gorge
|
||||
/// spans the region's chunks.
|
||||
fn generate_incised_gorge(
|
||||
region: &RegionProfile,
|
||||
chunk: &ChunkContext,
|
||||
@@ -1050,11 +1054,10 @@ fn generate_incised_gorge(
|
||||
| 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()
|
||||
};
|
||||
// Distance from the region-anchored gorge centreline (T-1041): continuous
|
||||
// world coordinates — folding into the 64 m chunk frame carved a gorge in
|
||||
// every chunk (D-239 §10 violation).
|
||||
let cross_from_centre = (cross - chunk.channel_anchor_m).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
|
||||
@@ -1083,8 +1086,11 @@ fn generate_incised_gorge(
|
||||
// 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;
|
||||
// Steep wall: 8 m rise per metre of wall distance (near-vertical, §8
|
||||
// Rock law). The rise saturates at the wall_top clamp below; clamp the
|
||||
// region-scale distance (T-1041) before multiplying — i32 overflow at
|
||||
// extreme coordinates otherwise.
|
||||
let wall_rise = wall_dist.min(16) * 8;
|
||||
let elev = (floor_elev + wall_rise + wall_noise).clamp(floor_elev, wall_top);
|
||||
(elev, Water::Dry)
|
||||
};
|
||||
@@ -1580,7 +1586,8 @@ fn compute_meander_reach_channel(
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||||
};
|
||||
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Region-anchored centreline (T-1040) — same anchor as AlluvialPlain.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
// Edge jitter from sub-chunk seed (±1 m), same as AlluvialPlain.
|
||||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||||
@@ -1621,7 +1628,8 @@ fn in_levee_band(voxel_pos: VoxelPos, chunk: &ChunkContext, sub_chunk_seed: u64)
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||||
};
|
||||
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Region-anchored centreline (T-1040) — same anchor as the channel itself.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||||
let half_width = (chunk.channel_width_m / 2).max(2);
|
||||
@@ -1690,8 +1698,10 @@ fn compute_channel_state(
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl
|
||||
};
|
||||
|
||||
// Perpendicular distance from voxel to channel centreline.
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Perpendicular distance from voxel to the region-anchored channel
|
||||
// centreline (T-1040): the channel axis is `chunk.channel_anchor_m` on the
|
||||
// cross axis — a region-scale world coordinate, never the world origin.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
// Sub-chunk noise: ±1 m jitter on the channel edge (wavelength < 64 m).
|
||||
// Use low bits of seed for integer jitter — D-010.
|
||||
@@ -1935,7 +1945,7 @@ pub const DEFAULT_VOXEL_CACHE_CAPACITY: usize = 8192;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::chunk_context::derive_chunk_context;
|
||||
use crate::atlas::chunk_context::{derive_chunk_context, BasinDirection};
|
||||
use crate::atlas::region_profile::{
|
||||
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
|
||||
};
|
||||
@@ -1944,6 +1954,29 @@ mod tests {
|
||||
// Test fixtures
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The ChunkContext of the region-(0,0) chunk whose cross-range contains
|
||||
/// the channel anchor — guaranteed inside the T-1040 channel band.
|
||||
/// Feature placement is region-anchored, so tests sample around
|
||||
/// `chunk.channel_anchor_m` instead of assuming chunk-frame positions.
|
||||
fn anchor_chunk(seed: u64, body: &str, region: &RegionProfile) -> ChunkContext {
|
||||
let probe = derive_chunk_context(seed, body, region, (0, 0));
|
||||
let idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||||
};
|
||||
derive_chunk_context(seed, body, region, pos)
|
||||
}
|
||||
|
||||
/// Map a (cross, along) coordinate pair to (tile_x, tile_y) for the
|
||||
/// chunk's basin axes — cross ⊥ basin_direction, along ∥ basin_direction.
|
||||
fn cross_along_to_tile(chunk: &ChunkContext, cross: i32, along: i32) -> (i32, i32) {
|
||||
match chunk.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (cross, along),
|
||||
BasinDirection::East | BasinDirection::West => (along, cross),
|
||||
}
|
||||
}
|
||||
|
||||
fn alluvial_region() -> RegionProfile {
|
||||
RegionProfile {
|
||||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||||
@@ -2340,33 +2373,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fjord_wall_centre_has_deep_water() {
|
||||
// The fjord inlet (centreline of the chunk) should have Deep water.
|
||||
// The fjord trough (region-anchored centreline, T-1041) must have Deep
|
||||
// water. Sample a small window around the anchor — the domain warp can
|
||||
// displace any single tile off the 4–8 m floor.
|
||||
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.
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let any_deep = (anchor - 4..=anchor + 4).any(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).water == Water::Deep
|
||||
});
|
||||
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)"
|
||||
any_deep,
|
||||
"FjordWall centreline (anchor {anchor}) must have Deep water (the fjord inlet)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2375,24 +2394,23 @@ mod tests {
|
||||
// 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).
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Floor: the lowest tile in the trough window around the anchor.
|
||||
let floor_elev = (anchor - 4..=anchor + 4)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).elevation_m
|
||||
})
|
||||
.min()
|
||||
.unwrap();
|
||||
// Wall: 30 m off the centreline (past the moraine band at ≤14 m).
|
||||
let (wx, wy) = cross_along_to_tile(&chunk, anchor - 30, 50);
|
||||
let wall_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, wx, wy);
|
||||
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,
|
||||
wall_col.elevation_m > floor_elev,
|
||||
"FjordWall walls ({}) must be higher than the floor ({})",
|
||||
wall_col.elevation_m,
|
||||
floor_col.elevation_m
|
||||
floor_elev
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2422,21 +2440,15 @@ mod tests {
|
||||
// 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.
|
||||
// Cross-section spans the region-anchored trough (T-1041).
|
||||
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
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Count Deep-water tiles across a 64-tile cross-section around the anchor.
|
||||
let deep_tiles = (anchor - 32..anchor + 32)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).water == Water::Deep
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
@@ -2480,15 +2492,17 @@ mod tests {
|
||||
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).
|
||||
// The coast face sits on the region-anchored line `coast_anchor_m` along
|
||||
// the seaward (basin) axis (T-1041) — sample a transect across it.
|
||||
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<VoxelColumn> = (0..64i32)
|
||||
.map(|x| derive_voxel_column(42, "Velen", ®ion, &chunk, x, 50))
|
||||
let coast = chunk.coast_anchor_m;
|
||||
// Sample 64 positions along the seaward axis, crossing the coast line.
|
||||
let cols: Vec<VoxelColumn> = (coast - 32..coast + 32)
|
||||
.map(|a| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, 50, a);
|
||||
derive_voxel_column(42, "Velen", ®ion, &chunk, tx, ty)
|
||||
})
|
||||
.collect();
|
||||
let has_deep = cols.iter().any(|c| c.water == Water::Deep);
|
||||
let max_elev = cols.iter().map(|c| c.elevation_m).max().unwrap();
|
||||
@@ -2577,18 +2591,22 @@ mod tests {
|
||||
#[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.
|
||||
// a range of positions across the fan belt should contain some Shallow
|
||||
// water. The belt is centred on the region-anchored fan axis (T-1041).
|
||||
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
|
||||
let chunk = anchor_chunk(42, "delta_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Sample 80 positions across the fan belt (anchor ± 40 covers the
|
||||
// 64 m belt plus thread width).
|
||||
let shallow_count = (anchor - 40..anchor + 40)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty).water
|
||||
== Water::Shallow
|
||||
})
|
||||
.count();
|
||||
// Three braided threads, each 3–8 m wide → expect at least 6 shallow tiles
|
||||
// in a 64-tile transect.
|
||||
// in the belt transect.
|
||||
assert!(
|
||||
shallow_count >= 6,
|
||||
"BraidedDelta must have multiple braided channels; only {shallow_count} shallow in transect"
|
||||
@@ -2602,9 +2620,13 @@ mod tests {
|
||||
// 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<Water> = (0..64i32)
|
||||
.map(|x| derive_voxel_column(42, "delta_body", ®ion, &chunk, x, 100).water)
|
||||
let chunk = anchor_chunk(42, "delta_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let waters: Vec<Water> = (anchor - 40..anchor + 40)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty).water
|
||||
})
|
||||
.collect();
|
||||
// Count Dry→Shallow transitions = number of channel entrances.
|
||||
let transitions = waters
|
||||
@@ -2766,25 +2788,15 @@ mod tests {
|
||||
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.
|
||||
// across a cross-section is within [2, 8] m. Cross-section spans the
|
||||
// region-anchored gorge centreline (T-1041).
|
||||
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
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let floor_tiles = (anchor - 32..anchor + 32)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty).water
|
||||
== Water::Shallow
|
||||
})
|
||||
.count();
|
||||
@@ -2799,15 +2811,24 @@ mod tests {
|
||||
// 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);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Floor: the lowest tile in the trough window around the anchor
|
||||
// (the warp can displace any single tile off the 2–8 m floor).
|
||||
let floor_elev = (anchor - 4..=anchor + 4)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty).elevation_m
|
||||
})
|
||||
.min()
|
||||
.unwrap();
|
||||
// Wall: 30 m off the centreline.
|
||||
let (wx, wy) = cross_along_to_tile(&chunk, anchor + 30, 100);
|
||||
let wall_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, wx, wy);
|
||||
assert!(
|
||||
wall_col.elevation_m > floor_col.elevation_m + 10,
|
||||
wall_col.elevation_m > floor_elev + 10,
|
||||
"IncisedGorge walls ({}) must be >> gorge floor ({})",
|
||||
wall_col.elevation_m,
|
||||
floor_col.elevation_m
|
||||
floor_elev
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2906,18 +2927,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn meander_reach_has_active_channel() {
|
||||
// MeanderReach must have a channel with Shallow/Deep water somewhere in a transect.
|
||||
// MeanderReach must have a channel with Shallow/Deep water somewhere in
|
||||
// a transect across the region-anchored channel band (T-1040).
|
||||
let region = meander_region();
|
||||
let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0));
|
||||
// The chunk has_active_channel = true (ocean_fraction_q=20 >= 10).
|
||||
let chunk = anchor_chunk(42, "meander_body", ®ion);
|
||||
// The anchor-covering chunk has an active channel (ocean_fraction_q=20 >= 10).
|
||||
assert!(
|
||||
chunk.has_active_channel,
|
||||
"MeanderReach test chunk must have active channel"
|
||||
"MeanderReach anchor chunk must have active channel"
|
||||
);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// 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);
|
||||
let wet_count = (anchor - 200..anchor + 200)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty);
|
||||
col.water != Water::Dry
|
||||
})
|
||||
.count();
|
||||
@@ -2929,31 +2953,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn meander_reach_channel_below_levee() {
|
||||
// D-239 §9 ElevationDelta: channel tiles must have lower elevation than
|
||||
// off-channel tiles in the vicinity.
|
||||
// D-239 §9 ElevationDelta: channel tiles must sit below off-channel
|
||||
// tiles in the vicinity. Per-voxel micro-relief is ±4 m, so a single
|
||||
// wet/dry tile pair is noise — compare the channel bottom (min wet)
|
||||
// against the floodplain/levee top (max dry) across the transect.
|
||||
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<i32> = None;
|
||||
let mut plain_elev: Option<i32> = 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;
|
||||
let chunk = anchor_chunk(42, "meander_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let mut min_wet: Option<i32> = None;
|
||||
let mut max_dry: Option<i32> = None;
|
||||
for c in anchor - 200..anchor + 200 {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty);
|
||||
if col.water != Water::Dry {
|
||||
min_wet = Some(min_wet.map_or(col.elevation_m, |e| e.min(col.elevation_m)));
|
||||
} else {
|
||||
max_dry = Some(max_dry.map_or(col.elevation_m, |e| e.max(col.elevation_m)));
|
||||
}
|
||||
}
|
||||
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})"
|
||||
);
|
||||
}
|
||||
// Both tile kinds must exist — a transect across the anchor that finds
|
||||
// no channel means the placement regressed (the law must be exercised).
|
||||
let (min_wet, max_dry) = (
|
||||
min_wet.expect("transect across the anchor must contain channel tiles"),
|
||||
max_dry.expect("transect must contain floodplain tiles"),
|
||||
);
|
||||
assert!(
|
||||
min_wet <= max_dry,
|
||||
"MeanderReach: channel bottom ({min_wet}) must be <= floodplain/levee top ({max_dry})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2984,31 +3011,41 @@ mod tests {
|
||||
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));
|
||||
// Same seed + body → same ChunkContext (same wavelength, phase, anchor,
|
||||
// channel_width). Use the anchor-covering chunk (T-1040 band gating).
|
||||
let meander_chunk = anchor_chunk(42, "sinuosity_body", &meander_reg);
|
||||
let alluvial_chunk = anchor_chunk(42, "sinuosity_body", &alluvial_reg);
|
||||
// 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_eq!(
|
||||
meander_chunk.channel_anchor_m, alluvial_chunk.channel_anchor_m,
|
||||
"test setup requires identical channel anchors"
|
||||
);
|
||||
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<i32> = (-400..400i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, x, 50).water
|
||||
// Collect wet-tile cross-positions over a transect at along=50, centred
|
||||
// on the shared anchor. We scan far enough to capture the full meander
|
||||
// swing — amplitude for MeanderReach is wl/4, for AlluvialPlain wl/6,
|
||||
// so over a ±400 sweep both complete multiple full cycles and their
|
||||
// centreline positions diverge.
|
||||
let anchor = meander_chunk.channel_anchor_m;
|
||||
let meander_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&meander_chunk, c, 50);
|
||||
derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, tx, ty)
|
||||
.water
|
||||
!= Water::Dry
|
||||
})
|
||||
.collect();
|
||||
let alluvial_wet: Vec<i32> = (-400..400i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, x, 50)
|
||||
let alluvial_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&alluvial_chunk, c, 50);
|
||||
derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, tx, ty)
|
||||
.water
|
||||
!= Water::Dry
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::atlas::chunk_context::derive_chunk_context;
|
||||
use settled_reach_server::atlas::chunk_context::{derive_chunk_context, BasinDirection, ChunkPos};
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
@@ -100,6 +100,37 @@ fn derive_golden(
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the chunk position + tile coordinates sitting ON the region's
|
||||
/// channel anchor at a given along-axis chunk index (T-1040/T-1041): feature
|
||||
/// placement is region-anchored, so the golden pins a voxel on the anchor
|
||||
/// column. Relocates automatically if the anchor derivation changes — which
|
||||
/// flips the golden values anyway.
|
||||
///
|
||||
/// `along_chunk` must stay within region (0, 0) — i.e. in `0..16` — so the
|
||||
/// probed anchor belongs to the same region as the returned chunk.
|
||||
fn anchor_golden_pos(
|
||||
seed: u64,
|
||||
body_id: &str,
|
||||
region: &RegionProfile,
|
||||
along_chunk: i32,
|
||||
) -> ((i32, i32), i32, i32) {
|
||||
assert!(
|
||||
(0..16).contains(&along_chunk),
|
||||
"along_chunk must stay within region (0, 0)"
|
||||
);
|
||||
let probe = derive_chunk_context(seed, body_id, region, (0, 0));
|
||||
let anchor = probe.channel_anchor_m;
|
||||
let along_tile = along_chunk * 64 + 32;
|
||||
match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => {
|
||||
((anchor.div_euclid(64), along_chunk), anchor, along_tile)
|
||||
}
|
||||
BasinDirection::East | BasinDirection::West => {
|
||||
((along_chunk, anchor.div_euclid(64)), along_tile, anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The three fixed golden inputs. Varied families and climate states.
|
||||
fn golden_cases() -> Vec<(
|
||||
&'static str,
|
||||
@@ -110,26 +141,45 @@ fn golden_cases() -> Vec<(
|
||||
i32,
|
||||
i32,
|
||||
)> {
|
||||
// Cases A and C pin voxels on the region channel anchor (T-1040/T-1041):
|
||||
// the channel/trough centreline is region-anchored, not at the world
|
||||
// origin (A) or the chunk centre (C).
|
||||
let alluvial_region = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::None,
|
||||
6,
|
||||
22,
|
||||
18,
|
||||
68,
|
||||
Some(12.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let (alluvial_chunk_pos, alluvial_tx, alluvial_ty) =
|
||||
anchor_golden_pos(0xdeadbeef_cafebabe_u64, "GJ144d", &alluvial_region, 12);
|
||||
let fjord_region = make_region(
|
||||
MorphologyZone::Fjord,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::Moderate,
|
||||
55,
|
||||
60,
|
||||
28,
|
||||
55,
|
||||
Some(-8.0),
|
||||
VegetationClass::Barren,
|
||||
);
|
||||
let (fjord_chunk_pos, fjord_tx, fjord_ty) =
|
||||
anchor_golden_pos(0xfeedface_0badc0de_u64, "GJ447c", &fjord_region, 5);
|
||||
vec![
|
||||
// Case A: AlluvialPlain — temperate forest, active channel.
|
||||
// Case A: AlluvialPlain — temperate forest, on the active-channel anchor.
|
||||
(
|
||||
"alluvial_forest_active_channel",
|
||||
0xdeadbeef_cafebabe_u64,
|
||||
"GJ144d",
|
||||
make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::None,
|
||||
6,
|
||||
22,
|
||||
18,
|
||||
68,
|
||||
Some(12.0),
|
||||
VegetationClass::Forest,
|
||||
),
|
||||
(10, 20),
|
||||
640,
|
||||
1280,
|
||||
alluvial_region,
|
||||
alluvial_chunk_pos,
|
||||
alluvial_tx,
|
||||
alluvial_ty,
|
||||
),
|
||||
// Case B: LavaField — barren volcanic, no channel.
|
||||
(
|
||||
@@ -151,26 +201,16 @@ fn golden_cases() -> Vec<(
|
||||
320,
|
||||
448,
|
||||
),
|
||||
// Case C: FjordWall — glaciated, rocky walls, deep water in trough.
|
||||
// Case C: FjordWall — glaciated, rocky walls; tile on the region-anchored
|
||||
// trough centreline (Deep water unless the warp nudges it onto the floor edge).
|
||||
(
|
||||
"fjord_wall_glaciated",
|
||||
0xfeedface_0badc0de_u64,
|
||||
"GJ447c",
|
||||
make_region(
|
||||
MorphologyZone::Fjord,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::Moderate,
|
||||
55,
|
||||
60,
|
||||
28,
|
||||
55,
|
||||
Some(-8.0),
|
||||
VegetationClass::Barren,
|
||||
),
|
||||
(14, 8),
|
||||
// Centre of the chunk (fjord trough) — should be Deep water, Rock.
|
||||
14 * 64 + 32,
|
||||
8 * 64 + 32,
|
||||
fjord_region,
|
||||
fjord_chunk_pos,
|
||||
fjord_tx,
|
||||
fjord_ty,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -320,8 +360,12 @@ fn law_drainage_monotonicity_alluvial_sweep() {
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let mut checked = false;
|
||||
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)] {
|
||||
checked |= assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, (cx, cy));
|
||||
// Sweep a spread of region chunks plus the channel-anchor band (T-1040:
|
||||
// the channel is region-anchored, so only band chunks carry wet tiles).
|
||||
let mut positions = vec![(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)];
|
||||
positions.extend(anchor_band_chunks(42, "GJ144d", ®ion, (0, 0)));
|
||||
for pos in positions {
|
||||
checked |= assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, pos);
|
||||
}
|
||||
assert!(
|
||||
checked,
|
||||
@@ -344,8 +388,10 @@ fn law_drainage_monotonicity_meander_sweep() {
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let mut checked = false;
|
||||
for (cx, cy) in [(0, 0), (2, 1), (5, 5)] {
|
||||
checked |= assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, (cx, cy));
|
||||
let mut positions = vec![(0, 0), (2, 1), (5, 5)];
|
||||
positions.extend(anchor_band_chunks(99, "GJ447c", ®ion, (0, 0)));
|
||||
for pos in positions {
|
||||
checked |= assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, pos);
|
||||
}
|
||||
assert!(
|
||||
checked,
|
||||
@@ -369,16 +415,27 @@ fn law_drainage_monotonicity_fjord_floor_at_sea_level() {
|
||||
Some(-8.0),
|
||||
VegetationClass::Barren,
|
||||
);
|
||||
let chunk = derive_chunk_context(42, "fjord_body", ®ion, (0, 0));
|
||||
// The fjord trough is region-anchored (T-1041): scan the chunk whose
|
||||
// cross-range contains the channel anchor — only that chunk column carries
|
||||
// the deep-water trough.
|
||||
let probe = derive_chunk_context(42, "fjord_body", ®ion, (0, 0));
|
||||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let chunk_pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (anchor_idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, anchor_idx),
|
||||
};
|
||||
let chunk = derive_chunk_context(42, "fjord_body", ®ion, chunk_pos);
|
||||
let (base_x, base_y) = (chunk_pos.0 * 64, chunk_pos.1 * 64);
|
||||
let mut deep_elevs: Vec<i32> = vec![];
|
||||
let mut dry_elevs: Vec<i32> = vec![];
|
||||
|
||||
// Scan the full 64×64 chunk, not a single row — the deep-water trough axis is
|
||||
// not guaranteed to intersect any fixed Y, so a single-row probe could miss it
|
||||
// entirely and silently pass without ever checking the sea-level claim.
|
||||
// not guaranteed to intersect any fixed row, so a single-row probe could miss
|
||||
// it entirely and silently pass without ever checking the sea-level claim.
|
||||
for dy in 0..64i32 {
|
||||
for dx in 0..64i32 {
|
||||
let col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, dx, dy);
|
||||
let col =
|
||||
derive_voxel_column(42, "fjord_body", ®ion, &chunk, base_x + dx, base_y + dy);
|
||||
match col.water {
|
||||
Water::Deep => deep_elevs.push(col.elevation_m),
|
||||
Water::Dry => dry_elevs.push(col.elevation_m),
|
||||
@@ -428,9 +485,12 @@ fn law_drainage_monotonicity_braided_delta() {
|
||||
VegetationClass::Scrub,
|
||||
);
|
||||
let mut checked = false;
|
||||
for (cx, cy) in [(0, 0), (1, 1)] {
|
||||
checked |=
|
||||
assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, (cx, cy));
|
||||
// The braid belt sits on the region's channel anchor (T-1041) — sweep the
|
||||
// anchor band so the law is exercised on real thread tiles.
|
||||
let mut positions = vec![(0, 0), (1, 1)];
|
||||
positions.extend(anchor_band_chunks(17, "delta_body", ®ion, (0, 0)));
|
||||
for pos in positions {
|
||||
checked |= assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, pos);
|
||||
}
|
||||
assert!(
|
||||
checked,
|
||||
@@ -898,6 +958,375 @@ fn law_climate_vegetation_airless_always_absent() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §2b — Region-anchored feature placement (T-1040 / T-1041, D-239 §10)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// T-1040: channel centrelines were anchored to the world x=0/y=0 axis — every
|
||||
// chunk of a watered region claimed has_active_channel while channel voxels
|
||||
// existed only near the world origin. T-1041: fjord/cliff/gorge/delta folded
|
||||
// world coordinates into the 64 m chunk frame — region-scale landforms
|
||||
// repeated every chunk. Both are fixed by region-anchored feature axes
|
||||
// (`channel_anchor_m` / `coast_anchor_m`); these tests pin the placement
|
||||
// contract far from the origin, at the ticket's example chunk (1000, −750).
|
||||
|
||||
#[test]
|
||||
fn channel_present_in_active_chunks_far_from_origin() {
|
||||
// T-1040 (a): a has_active_channel chunk at an arbitrary large world
|
||||
// offset contains in-channel voxels — and the gate is honest in both
|
||||
// directions (wet ⇒ gated, ungated ⇒ dry). Sweeps the 16 cross-columns of
|
||||
// the region containing chunk (1000, −750) at that chunk's along index.
|
||||
let region = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Stable,
|
||||
GlaciationGrade::None,
|
||||
5,
|
||||
20,
|
||||
18,
|
||||
60,
|
||||
Some(15.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let (seed, body) = (42u64, "GJ144d");
|
||||
let ns = basin_is_ns(seed, body, ®ion, (1000, -750));
|
||||
let cross_base = if ns {
|
||||
(1000 >> 4) << 4
|
||||
} else {
|
||||
(-750i32 >> 4) << 4
|
||||
};
|
||||
let mut any_gated_wet = false;
|
||||
let mut any_gate_off = false;
|
||||
let mut total_wet = 0usize;
|
||||
for i in 0..16 {
|
||||
let pos: ChunkPos = if ns {
|
||||
(cross_base + i, -750)
|
||||
} else {
|
||||
(1000, cross_base + i)
|
||||
};
|
||||
let chunk = derive_chunk_context(seed, body, ®ion, pos);
|
||||
let (bx, by) = (pos.0 * 64, pos.1 * 64);
|
||||
let mut wet = 0usize;
|
||||
for dy in 0..64i32 {
|
||||
for dx in 0..64i32 {
|
||||
let col = derive_voxel_column(seed, body, ®ion, &chunk, bx + dx, by + dy);
|
||||
if col.water != Water::Dry {
|
||||
wet += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
total_wet += wet;
|
||||
if chunk.has_active_channel {
|
||||
any_gated_wet |= wet > 0;
|
||||
} else {
|
||||
any_gate_off = true;
|
||||
assert_eq!(
|
||||
wet, 0,
|
||||
"T-1040: chunk {pos:?} has no active channel but contains {wet} wet voxels \
|
||||
— the chunk gate and the voxel placement disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
total_wet > 0,
|
||||
"T-1040: the region at chunk (1000, −750) must contain channel voxels \
|
||||
(pre-fix: zero — channels existed only near the world-origin axis)"
|
||||
);
|
||||
assert!(
|
||||
any_gated_wet,
|
||||
"T-1040: at least one has_active_channel chunk must contain in-channel voxels"
|
||||
);
|
||||
assert!(
|
||||
any_gate_off,
|
||||
"T-1040: the channel band must not blanket the region — some chunks must gate off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_continuous_across_chunk_boundary_far_from_origin() {
|
||||
// T-1040 (a): channel position is continuous across adjacent chunk pairs.
|
||||
// Every tile derives under its PRODUCTION covering chunk; the wet band's
|
||||
// midpoint may not jump at a 64 m along-boundary (chunk-frame dependence
|
||||
// would jump by up to a chunk width or drop out entirely).
|
||||
let region = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Stable,
|
||||
GlaciationGrade::None,
|
||||
5,
|
||||
20,
|
||||
18,
|
||||
60,
|
||||
Some(15.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let (seed, body) = (42u64, "GJ144d");
|
||||
let probe = derive_chunk_context(seed, body, ®ion, (1000, -750));
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
let anchor = probe.channel_anchor_m;
|
||||
// Wet-band midpoint of one world cross-row, sampling the full swept band.
|
||||
let row_mid = |along: i32| -> Option<i32> {
|
||||
let wet: Vec<i32> = (anchor - 220..anchor + 220)
|
||||
.filter(|&c| {
|
||||
derive_at_cross_along(seed, body, ®ion, ns, c, along).water != Water::Dry
|
||||
})
|
||||
.collect();
|
||||
wet.first().map(|f| (f + wet.last().unwrap()) / 2)
|
||||
};
|
||||
// An interior along-boundary of the region containing chunk (1000, −750):
|
||||
// between along-chunks −744 and −743 (N/S) or 1004 and 1005 (E/W).
|
||||
let boundary = if ns { -743 * 64 } else { 1005 * 64 };
|
||||
let mut prev: Option<i32> = None;
|
||||
for along in boundary - 4..boundary + 4 {
|
||||
let mid = row_mid(along).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"T-1040: row along={along} contains no wet tiles — the channel \
|
||||
dropped out at the chunk boundary {boundary}"
|
||||
)
|
||||
});
|
||||
if let Some(p) = prev {
|
||||
assert!(
|
||||
(mid - p).abs() <= 12,
|
||||
"T-1040: wet-band midpoint jumped {} m between adjacent rows \
|
||||
{} and {} (boundary {boundary}) — channel is not continuous",
|
||||
(mid - p).abs(),
|
||||
along - 1,
|
||||
along
|
||||
);
|
||||
}
|
||||
prev = Some(mid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fjord_region_has_one_valley_spanning_chunks() {
|
||||
// T-1041 (b): a FjordWall region contains ONE deep-water trough spanning
|
||||
// its chunks — pre-fix a complete fjord cross-section repeated in every
|
||||
// 64 m chunk. Representative sweep: two far regions × three along rows.
|
||||
let region = make_region(
|
||||
MorphologyZone::Fjord,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::Moderate,
|
||||
55,
|
||||
60,
|
||||
28,
|
||||
55,
|
||||
Some(-8.0),
|
||||
VegetationClass::Barren,
|
||||
);
|
||||
let (seed, body) = (42u64, "fjord_body");
|
||||
for region_chunk in [(640, -480), (-336, 992)] {
|
||||
let probe = derive_chunk_context(seed, body, ®ion, region_chunk);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
let (cross_chunk, along_chunk) = if ns {
|
||||
(region_chunk.0, region_chunk.1)
|
||||
} else {
|
||||
(region_chunk.1, region_chunk.0)
|
||||
};
|
||||
let cross_base = (cross_chunk >> 4) * 1024;
|
||||
let along_base = (along_chunk >> 4) * 1024;
|
||||
let positions: Vec<i32> = (cross_base..cross_base + 1024).collect();
|
||||
for along in [along_base + 32, along_base + 512, along_base + 992] {
|
||||
let clusters = count_feature_clusters(
|
||||
&positions,
|
||||
|c| derive_at_cross_along(seed, body, ®ion, ns, c, along).water == Water::Deep,
|
||||
16,
|
||||
);
|
||||
assert_eq!(
|
||||
clusters, 1,
|
||||
"T-1041: FjordWall region {region_chunk:?} must contain exactly ONE \
|
||||
deep-water trough across its 1024 m cross extent at along={along} \
|
||||
(got {clusters}; pre-fix: one per 64 m chunk)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gorge_region_has_one_valley_spanning_chunks() {
|
||||
// T-1041 (b): an IncisedGorge region contains ONE shallow-floor gorge
|
||||
// spanning its chunks — not one per chunk.
|
||||
let region = make_region(
|
||||
MorphologyZone::MountainPass,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::None,
|
||||
50,
|
||||
65,
|
||||
5,
|
||||
40,
|
||||
Some(5.0),
|
||||
VegetationClass::Scrub,
|
||||
);
|
||||
let (seed, body) = (42u64, "gorge_body");
|
||||
for region_chunk in [(640, -480), (-336, 992)] {
|
||||
let probe = derive_chunk_context(seed, body, ®ion, region_chunk);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
let (cross_chunk, along_chunk) = if ns {
|
||||
(region_chunk.0, region_chunk.1)
|
||||
} else {
|
||||
(region_chunk.1, region_chunk.0)
|
||||
};
|
||||
let cross_base = (cross_chunk >> 4) * 1024;
|
||||
let along_base = (along_chunk >> 4) * 1024;
|
||||
let positions: Vec<i32> = (cross_base..cross_base + 1024).collect();
|
||||
for along in [along_base + 32, along_base + 512, along_base + 992] {
|
||||
let clusters = count_feature_clusters(
|
||||
&positions,
|
||||
|c| {
|
||||
derive_at_cross_along(seed, body, ®ion, ns, c, along).water == Water::Shallow
|
||||
},
|
||||
16,
|
||||
);
|
||||
assert_eq!(
|
||||
clusters, 1,
|
||||
"T-1041: IncisedGorge region {region_chunk:?} must contain exactly ONE \
|
||||
shallow gorge floor across its 1024 m cross extent at along={along} \
|
||||
(got {clusters}; pre-fix: one per 64 m chunk)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cliff_coast_one_continuous_coastline_per_region() {
|
||||
// T-1041 (b): a CliffCoast region has ONE continuous (warp-displaced)
|
||||
// coast line on the region-anchored face — pre-fix the cliff face sat at
|
||||
// intra-chunk offset 48–55 in every chunk, sawtoothing the coast at 64 m
|
||||
// pitch. Transect runs along the seaward (basin) axis across the region.
|
||||
let region = make_region(
|
||||
MorphologyZone::CliffCoast,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::None,
|
||||
60,
|
||||
40,
|
||||
20,
|
||||
30,
|
||||
Some(10.0),
|
||||
VegetationClass::Scrub,
|
||||
);
|
||||
let (seed, body) = (42u64, "cliff_body");
|
||||
for region_chunk in [(656, -464), (-256, 768)] {
|
||||
let probe = derive_chunk_context(seed, body, ®ion, region_chunk);
|
||||
let coast = probe.coast_anchor_m;
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
let (cross_chunk, along_chunk) = if ns {
|
||||
(region_chunk.0, region_chunk.1)
|
||||
} else {
|
||||
(region_chunk.1, region_chunk.0)
|
||||
};
|
||||
let along_base = (along_chunk >> 4) * 1024;
|
||||
let cross_fixed = cross_chunk * 64 + 32;
|
||||
let positions: Vec<i32> = (along_base..along_base + 1024).collect();
|
||||
// Exactly one ocean cluster (the seaward side of the one coast line).
|
||||
let clusters = count_feature_clusters(
|
||||
&positions,
|
||||
|a| derive_at_cross_along(seed, body, ®ion, ns, cross_fixed, a).water == Water::Deep,
|
||||
16,
|
||||
);
|
||||
assert_eq!(
|
||||
clusters, 1,
|
||||
"T-1041: CliffCoast region {region_chunk:?} must have exactly ONE ocean \
|
||||
side (got {clusters} Deep clusters; pre-fix: one 64 m sawtooth per chunk)"
|
||||
);
|
||||
// Monotone coast: well inland of the face line → Dry; well seaward →
|
||||
// Deep. Margins absorb the ±8 m domain warp (face 0..8, ledge 8..13).
|
||||
for &a in &positions {
|
||||
// Signed seaward distance — mirrors generate_cliff_coast.
|
||||
let d = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::West => coast - a,
|
||||
BasinDirection::South | BasinDirection::East => a - coast,
|
||||
};
|
||||
let col = derive_at_cross_along(seed, body, ®ion, ns, cross_fixed, a);
|
||||
if d <= -9 {
|
||||
assert_eq!(
|
||||
col.water,
|
||||
Water::Dry,
|
||||
"T-1041: tile {} m inland of the coast line must be Dry (along={a})",
|
||||
-d
|
||||
);
|
||||
} else if d >= 21 {
|
||||
assert_eq!(
|
||||
col.water,
|
||||
Water::Deep,
|
||||
"T-1041: tile {d} m seaward of the coast line must be Deep (along={a})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braided_threads_confined_to_region_belt() {
|
||||
// T-1041: braided threads anastomose across the region-anchored fan belt
|
||||
// (anchor ± 32 m + thread width + warp) — pre-fix the same three threads
|
||||
// restarted in every 64 m chunk, spreading thread water across the whole
|
||||
// region's cross extent.
|
||||
let region = make_region(
|
||||
MorphologyZone::Delta,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::None,
|
||||
3,
|
||||
8,
|
||||
25,
|
||||
50,
|
||||
Some(18.0),
|
||||
VegetationClass::Scrub,
|
||||
);
|
||||
let (seed, body) = (17u64, "delta_body");
|
||||
for region_chunk in [(800, -592)] {
|
||||
let probe = derive_chunk_context(seed, body, ®ion, region_chunk);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
let anchor = probe.channel_anchor_m;
|
||||
let (cross_chunk, along_chunk) = if ns {
|
||||
(region_chunk.0, region_chunk.1)
|
||||
} else {
|
||||
(region_chunk.1, region_chunk.0)
|
||||
};
|
||||
let cross_base = (cross_chunk >> 4) * 1024;
|
||||
let along = along_chunk * 64 + 32;
|
||||
let wet: Vec<i32> = (cross_base..cross_base + 1024)
|
||||
.filter(|&c| {
|
||||
derive_at_cross_along(seed, body, ®ion, ns, c, along).water == Water::Shallow
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
!wet.is_empty(),
|
||||
"T-1041: BraidedDelta region {region_chunk:?} must contain thread water"
|
||||
);
|
||||
// Belt confinement: thread centres ∈ anchor ± 32, half-width ≤ 4,
|
||||
// warp ≤ 8 → all thread water within anchor ± 44.
|
||||
for &c in &wet {
|
||||
assert!(
|
||||
(c - anchor).abs() <= 44,
|
||||
"T-1041: thread water at cross={c} is {} m from the fan axis {anchor} \
|
||||
— outside the region belt (pre-fix: threads repeated every chunk)",
|
||||
(c - anchor).abs()
|
||||
);
|
||||
}
|
||||
// Braided, not single-thread: 1–3 thread clusters within the belt
|
||||
// (three threads, possibly merged where centres overlap; warp-scale
|
||||
// gap tolerance — the ±8 m warp punches small holes in a thread).
|
||||
let cluster_count = count_feature_clusters(&wet, |_| true, 8);
|
||||
assert!(
|
||||
(1..=3).contains(&cluster_count),
|
||||
"T-1041: expected 1–3 braided thread clusters in the belt, got {cluster_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §3 — Per-family <5 ms/chunk budget (D-239 §10)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1446,6 +1875,92 @@ fn validation_gruenfeld_skipped_not_in_wiki() {
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether the region's basin runs north/south (cross axis = x). Region-scale
|
||||
/// property — identical for every chunk of the region containing `region_chunk`.
|
||||
fn basin_is_ns(seed: u64, body_id: &str, region: &RegionProfile, region_chunk: ChunkPos) -> bool {
|
||||
let probe = derive_chunk_context(seed, body_id, region, region_chunk);
|
||||
matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
)
|
||||
}
|
||||
|
||||
/// The chunk positions of the channel-anchor band (anchor column ± 1) across
|
||||
/// the full along-extent of the region containing `region_chunk` (T-1040).
|
||||
///
|
||||
/// The channel/landform centreline is region-anchored: it lives in the anchor
|
||||
/// chunk column, swinging up to one meander amplitude sideways. The meander
|
||||
/// wavelength (≤ ~650 m) fits inside the region's 1024 m along-extent, so the
|
||||
/// centreline crosses the anchor column at least once — sweeping this band
|
||||
/// guarantees wet tiles are exercised somewhere in it.
|
||||
fn anchor_band_chunks(
|
||||
seed: u64,
|
||||
body_id: &str,
|
||||
region: &RegionProfile,
|
||||
region_chunk: ChunkPos,
|
||||
) -> Vec<ChunkPos> {
|
||||
let probe = derive_chunk_context(seed, body_id, region, region_chunk);
|
||||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
);
|
||||
// Region base index on the along axis (16-chunk regions; arithmetic shift
|
||||
// = floor division, correct for negative chunks).
|
||||
let along_base = if ns {
|
||||
(region_chunk.1 >> 4) << 4
|
||||
} else {
|
||||
(region_chunk.0 >> 4) << 4
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for cross in anchor_idx - 1..=anchor_idx + 1 {
|
||||
for j in along_base..along_base + 16 {
|
||||
out.push(if ns { (cross, j) } else { (j, cross) });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Derive one voxel at world cross/along coordinates under the PRODUCTION
|
||||
/// covering chunk's context (per-tile chunk lookup — exactly what a consumer
|
||||
/// streaming the world does). Basis-aware: cross ⊥ basin, along ∥ basin.
|
||||
fn derive_at_cross_along(
|
||||
seed: u64,
|
||||
body_id: &str,
|
||||
region: &RegionProfile,
|
||||
ns: bool,
|
||||
cross: i32,
|
||||
along: i32,
|
||||
) -> settled_reach_server::atlas::voxel::VoxelColumn {
|
||||
let (tx, ty) = if ns { (cross, along) } else { (along, cross) };
|
||||
let chunk_pos = (tx.div_euclid(64), ty.div_euclid(64));
|
||||
let chunk = derive_chunk_context(seed, body_id, region, chunk_pos);
|
||||
derive_voxel_column(seed, body_id, region, &chunk, tx, ty)
|
||||
}
|
||||
|
||||
/// Count clusters of positions where `pred` holds across a cross/along
|
||||
/// transect, merging runs separated by gaps ≤ `gap_tolerance` (domain-warp
|
||||
/// jitter can fragment a single feature by a few metres; distinct per-chunk
|
||||
/// repeats are ≥ ~50 m apart and never merge).
|
||||
fn count_feature_clusters(
|
||||
positions: &[i32],
|
||||
hits: impl Fn(i32) -> bool,
|
||||
gap_tolerance: i32,
|
||||
) -> usize {
|
||||
let mut clusters = 0usize;
|
||||
let mut last_hit: Option<i32> = None;
|
||||
for &p in positions {
|
||||
if hits(p) {
|
||||
match last_hit {
|
||||
Some(prev) if p - prev <= gap_tolerance => {}
|
||||
_ => clusters += 1,
|
||||
}
|
||||
last_hit = Some(p);
|
||||
}
|
||||
}
|
||||
clusters
|
||||
}
|
||||
|
||||
/// Construct a `RegionProfile` directly from parameters, deriving the
|
||||
/// dependent fields (precipitation_class, river_threshold) consistently.
|
||||
fn make_region(
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"label": "alluvial_forest_active_channel",
|
||||
"seed": 16045690984503098046,
|
||||
"body_id": "GJ144d",
|
||||
"tile_x": 640,
|
||||
"tile_y": 1280,
|
||||
"tile_x": 223,
|
||||
"tile_y": 800,
|
||||
"terrain": 0,
|
||||
"vegetation": 1,
|
||||
"water": 0,
|
||||
"elevation_m": 11,
|
||||
"vegetation": 4,
|
||||
"water": 1,
|
||||
"elevation_m": 8,
|
||||
"cover": 0
|
||||
},
|
||||
{
|
||||
@@ -27,12 +27,12 @@
|
||||
"label": "fjord_wall_glaciated",
|
||||
"seed": 18369614217980264670,
|
||||
"body_id": "GJ447c",
|
||||
"tile_x": 928,
|
||||
"tile_y": 544,
|
||||
"tile_x": 228,
|
||||
"tile_y": 352,
|
||||
"terrain": 3,
|
||||
"vegetation": 0,
|
||||
"water": 2,
|
||||
"elevation_m": 1,
|
||||
"elevation_m": 0,
|
||||
"cover": 1
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user