//! ChunkContext — 64 m carrier of the D-239 three-tier refinement chain (T-1028). //! //! `ChunkContext` is the middle tier: coarser than a voxel (1 m), finer than a //! district (2 km, D-243). It is derived purely from the covering `DistrictProfile`(s) and //! the world seed — no stored state, no side effects. //! //! ## Scale contract (D-239 §10) //! //! A chunk is 64 m × 64 m. The D8 drainage direction grid is ~152 m/cell — //! **coarser than a chunk** — so there is no per-tile `flow_direction[64×64]` //! here. Instead the chunk carries: //! - **`basin_direction`** — a cardinal basin-flow direction (N/E/S/W, 4-way) //! derived from the dominant D8 thalweg in the covering district. //! - **`meander_phase`** and **`meander_wavelength_m`** — global meander-curve //! params for the MeanderReach and AlluvialPlain families. //! //! Features with wavelength > 64 m seed from **District-or-higher** (the covering //! `DistrictProfile`), 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 district** //! from the district-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. //! //! ## Cross-district blending (T-1042, D-239 §4/§7) //! //! Chunks within one chunk (64 m) of a district boundary carry a `secondary` //! `DistrictProfile` and a `blend_weight` (255 = 100% primary; 128 = 50-50 blend). //! The blend applies to **continuous positional params only**: `meander_wavelength_m`, //! `channel_width_m`, `meander_phase` (all pre-blended here on the context) and //! `elev_q` / `moisture_q` (blended in `derive_voxel_column` before family dispatch). //! //! **Morphology family seams stay sharp** (D-239 §7): `secondary` is carried for //! elevation/moisture blending only; the primary district's `morphology_zone` is //! never overridden by the secondary. //! //! ## D-010 compliance //! //! All derivation is integer arithmetic. The only f64 in this module is //! `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 //! //! Uses `SeedDomain::ChunkContext = 8` (appended, never renumber). use serde::{Deserialize, Serialize}; use crate::atlas::district_profile::DistrictProfile; use crate::atlas::scale; use crate::seed::{SeedChain, SeedDomain}; // --------------------------------------------------------------------------- // Basin direction (cardinal, 4-way) // --------------------------------------------------------------------------- /// Cardinal basin-flow direction — the dominant D8 thalweg direction in the /// covering district. Coarser than a chunk (D-239 §10); derived from district slope /// and morphology, NOT from a per-tile D8 grid. /// /// Integer-discriminant, append-only (D-010). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[repr(u8)] pub enum BasinDirection { /// Flow toward the north (decreasing y in grid coords). #[default] North = 0, /// Flow toward the east (increasing x in grid coords). East = 1, /// Flow toward the south (increasing y in grid coords). South = 2, /// Flow toward the west (decreasing x in grid coords). West = 3, } // --------------------------------------------------------------------------- // ChunkPos — position on the 64 m chunk grid // --------------------------------------------------------------------------- /// Chunk grid position + the chunk edge metres come from the canonical ladder /// ([`crate::atlas::scale`], D-243) — chunk_context no longer defines its own /// scale (the Q-110 failure mode). The covering district is `scale::DISTRICT_M` /// = 2 048 m = `scale::CHUNKS_PER_DISTRICT` (32) chunks; the district-index /// mapping is `chunk >> scale::CHUNK_DISTRICT_SHIFT`. pub use crate::atlas::scale::{ChunkPos, CHUNK_M}; /// Margin keeping a district's feature anchor away from the district 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 district. /// Cross-district feature continuity is the stage-2 Voronoi model (T-1040). const ANCHOR_MARGIN_M: i32 = 192; /// Seed-addressable anchor span within a district (`scale::DISTRICT_M` − 2 × margin). const ANCHOR_SPAN_M: i32 = scale::DISTRICT_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 // --------------------------------------------------------------------------- /// 64 m carrier derived from `DistrictProfile`(s) — the second tier of D-239 §1. /// /// Pure deterministic function of `(seed, body_id, district, chunk_pos)`. /// Never stored; derived on demand and cached (D-227). /// /// ## Fields /// /// - `basin_direction` — dominant drainage direction (cardinal) for this chunk. /// - `meander_phase` — integer phase offset (0–255) for the meander curve. /// Used by MeanderReach and AlluvialPlain voxel generators to place the channel. /// When `blend_weight < 255`, this is already blended between the primary and /// secondary district values (T-1042). /// - `meander_wavelength_m` — meander wavelength in metres. Derived from /// district-level morphology (slope, moisture), seeded at district scale (> 64 m). /// f64 for positional physics (D-239 §4); structural decisions consume it /// only via deterministic i32 truncation (the `has_active_channel` band). /// When `blend_weight < 255`, this is already blended (T-1042). /// - `has_active_channel` — whether a water channel is present in this chunk: /// the district 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. When `blend_weight < 255`, this is already blended (T-1042). /// - `channel_anchor_m` / `coast_anchor_m` — district-anchored feature axes in /// world metres (T-1040/T-1041, D-239 §10). /// - `secondary` — adjacent district profile for cross-district blending (T-1042, /// D-239 §4). `None` when the chunk is interior (≥ 1 chunk from any district edge). /// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from this /// in `derive_voxel_column`; morphology family stays primary (D-239 §7). /// - `blend_weight` — blend weight toward the primary district. 255 = fully primary /// (no blend), 128 = 50-50 blend. Meaningful only when `secondary` is `Some`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChunkContext { /// Dominant basin-flow direction for this chunk's drainage catchment. pub basin_direction: BasinDirection, /// Integer phase offset for the meander curve (0–255). /// Derived at district scale (wavelength > 64 m), NOT from the chunk seed. /// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042). pub meander_phase: u8, /// Meander wavelength in metres. Positional physics value (f64 — D-239 §4). /// Derived at district scale; typically 80–500 m for AlluvialPlain. /// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042). pub meander_wavelength_m: f64, /// Whether this chunk contains an active water channel. /// True when the district 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 district says, not /// district-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 district morphology and slope. /// Pre-blended between primary and secondary when `blend_weight < 255` (T-1042). pub channel_width_m: i32, /// Cross-axis world-metre coordinate of the district's feature centreline: /// channel/meander axis, fjord trough, gorge floor, braid-fan axis. /// Derived once per district from the district-scale seed (T-1040/T-1041, /// D-239 §10) — constant across all chunks of a district, 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. District-scale (T-1041): one continuous coast per district, not a /// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins. pub coast_anchor_m: i32, /// Adjacent-district profile for cross-district terrain blending (T-1042, /// D-239 §4/§7). `None` for interior chunks (≥ 1 chunk from any district edge). /// /// Only continuous terrain params (`elev_q`, `moisture_q`) are blended from /// this in `derive_voxel_column`. Morphology family selection always uses the /// **primary** district's zone — seams stay sharp (D-239 §7). /// `meander_wavelength_m`, `channel_width_m`, `meander_phase` are blended /// here at context derivation time. pub secondary: Option, /// Blend weight toward the primary district (D-010 integer arithmetic). /// /// - `255` — fully primary; `secondary` is ignored (no blend). /// - `128` — 50-50 blend (chunk at the district boundary). /// /// Meaningful only when `secondary` is `Some`. Formula used throughout: /// `blended = (primary * blend_weight as i32 + secondary * (255 - blend_weight) as i32 + 127) / 255` /// (rounded integer lerp, symmetric for 128). pub blend_weight: u8, } // --------------------------------------------------------------------------- // Derivation // --------------------------------------------------------------------------- /// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid. /// /// Pure function of `(seed, body_id, district, chunk_pos, secondary)`. /// /// `secondary` supplies the adjacent `DistrictProfile` and a `blend_weight` /// (255 = fully primary, 128 = 50-50) for cross-district terrain blending /// (T-1042, D-239 §4/§7). Pass `None` for interior chunks. The caller is /// responsible for detecting whether the chunk lies within one chunk (64 m) /// of a district boundary and supplying the adjacent profile. /// /// When `secondary` is `Some`, the context-level continuous params /// (`meander_wavelength_m`, `channel_width_m`, `meander_phase`) are blended /// here using integer arithmetic (D-010). The voxel-level params (`elev_q`, /// `moisture_q`) are blended in `derive_voxel_column` before family dispatch. /// Morphology family selection always uses the **primary** district's zone — /// seams stay sharp (D-239 §7). /// /// ## Seed usage /// /// The **meander phase** and **basin direction** are seeded at district scale so /// features with wavelength > 64 m are consistent across chunk boundaries. /// The chunk-local seed (keyed on `chunk_pos`) is reserved for sub-chunk scatter /// in the voxel pass — not consumed here. pub fn derive_chunk_context( world_seed: u64, body_id: &str, district: &DistrictProfile, chunk_pos: ChunkPos, secondary: Option<(&DistrictProfile, u8)>, ) -> ChunkContext { // District-scale seed — features with wavelength > 64 m derive from here. // Keyed on the chunk position mapped to district-scale units: `>> // scale::CHUNK_DISTRICT_SHIFT` gives the 2 km district index (32 chunks). // MUST match the same shift in `derive_district_anchor` so the seed-district // and the anchor-district are the same cell (D-243; the canonical ladder). let district_scale_id = pos_to_id(( chunk_pos.0 >> scale::CHUNK_DISTRICT_SHIFT, chunk_pos.1 >> scale::CHUNK_DISTRICT_SHIFT, )); let district_seed = SeedChain::for_body(world_seed, body_id) .derive(SeedDomain::ChunkContext, district_scale_id); // Basin direction — derived from district slope_q (which encodes the // dominant terrain gradient). We use the district's `elev_q` gradient // direction as a proxy for the D8 thalweg direction. // All integer arithmetic (D-010). let basin_direction = derive_basin_direction(district, district_seed.seed()); // Meander phase — district-scale integer offset so the channel is consistent // across all chunks in the same district. 0–255. let primary_meander_phase = (district_seed.seed() >> 8) as u8; // Meander wavelength — derived from slope and morphology, district-scale. // Lower slope → longer wavelength (wider meanders); integer inputs, f64 result // is positional physics (D-239 §4, not a gate comparison). let primary_wavelength_m = derive_meander_wavelength(district); // Channel width from primary district — integer metres (D-010). let primary_channel_width = derive_channel_width(district); // District-anchored feature axes (T-1040/T-1041, D-239 §10): channel and // landform centrelines have wavelength > 64 m, so their position derives // from the district-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_district_anchor(cross_chunk, (district_seed.seed() >> 16) & 0xFFFF); let coast_anchor_m = derive_district_anchor(along_chunk, (district_seed.seed() >> 32) & 0xFFFF); // ── Cross-district blending of context-level continuous params (T-1042) ── // // When a secondary district is supplied, blend `meander_wavelength_m`, // `channel_width_m`, and `meander_phase` between the primary and secondary // district values. These are the context-level continuous positional params // (D-239 §4; spec: "blend them on the context, do NOT re-read from the // profile in the blend path"). All integer arithmetic (D-010). // // Morphology family, basin direction, feature anchors, and has_active_channel // are NOT blended — they are structural decisions driven by the primary // district only (D-239 §7: morphology seams stay sharp). let (blend_weight, secondary_stored) = match secondary { Some((sec, weight)) => { let sec_wavelength = derive_meander_wavelength(sec); let sec_channel_width = derive_channel_width(sec); let sec_phase = { // Secondary district's meander phase uses a secondary seed so it // differs from the primary (different district-scale chunk id). // We approximate: use the secondary's wavelength-derived phase // as an integer representation of its channel geometry. For a // well-defined phase, we re-derive from the secondary's slope and // moisture as a deterministic position-independent proxy. // Integer arithmetic (D-010). (sec.slope_q.wrapping_add(sec.moisture_q) as u8) .wrapping_add(primary_meander_phase / 2) }; let w = weight as i32; let w_sec = 255 - w; // Integer lerp: `(a * w + b * w_sec + 127) / 255`. // The +127 biases the division round to nearest (symmetric at w=128). let blended_phase = ((primary_meander_phase as i32 * w + sec_phase as i32 * w_sec + 127) / 255) as u8; let blended_wavelength = (primary_wavelength_m * w as f64 + sec_wavelength * w_sec as f64) / 255.0; let blended_channel_width = (primary_channel_width * w + sec_channel_width * w_sec + 127) / 255; ( weight, Some(( blended_phase, blended_wavelength, blended_channel_width, sec.clone(), )), ) } None => (255u8, None), }; let (meander_phase, meander_wavelength_m, primary_channel_width_final, secondary_profile) = match secondary_stored { Some((ph, wl, cw, prof)) => (ph, wl, cw, Some(prof)), None => ( primary_meander_phase, primary_wavelength_m, primary_channel_width, None, ), }; // Active channel — water presence (ocean_fraction_q >= 10 indicates a // perennial waterway or water body covers at least 10% of the district) AND // the channel's swept band around the district 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 has_active_channel = district.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 = (primary_channel_width_final / 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 { primary_channel_width_final } else { 0 }; ChunkContext { basin_direction, meander_phase, meander_wavelength_m, has_active_channel, channel_width_m, channel_anchor_m, coast_anchor_m, secondary: secondary_profile, blend_weight, } } /// Compute the cross-district blend weight for a chunk position (T-1042). /// /// Returns `(is_near_boundary, blend_weight)` for the given chunk's district /// proximity on either axis. `blend_weight` is 255 when interior (no blend), /// or 128 when the chunk is the outermost within its district on either axis /// (one chunk from the district boundary). The `is_near_boundary` flag is `true` /// only when `blend_weight < 255`. /// /// The caller uses `is_near_boundary` to decide whether to look up the adjacent /// `DistrictProfile` and supply it to `derive_chunk_context`. Only the last chunk /// of a district (chunk index `CHUNKS_PER_DISTRICT - 1` = 31 within the district) /// triggers a blend; the first chunk of the next district does not — this way the /// blend seam is always on the outgoing side, and the incoming district's first /// chunk reads clean from its own primary profile. /// /// Integer arithmetic (D-010). pub fn district_boundary_blend_weight(chunk_pos: ChunkPos) -> (bool, u8) { // Chunk index within its district on each axis (0..32). let cx = chunk_pos.0.rem_euclid(scale::CHUNKS_PER_DISTRICT); let cy = chunk_pos.1.rem_euclid(scale::CHUNKS_PER_DISTRICT); // The last chunk (index 31) is within 64 m of the district boundary. let near_x = cx == scale::CHUNKS_PER_DISTRICT - 1; let near_y = cy == scale::CHUNKS_PER_DISTRICT - 1; if near_x || near_y { (true, 128u8) } else { (false, 255u8) } } /// World-metre anchor coordinate for a district-scale feature axis on one axis. /// /// The anchor sits in `[district_origin + ANCHOR_MARGIN_M, district_origin + /// scale::DISTRICT_M − ANCHOR_MARGIN_M)` so the feature's full swept band stays /// inside its district (no cross-district band spill; district-seam continuity is /// the stage-2 Voronoi model, T-1040). Same value for every chunk of the district: /// the district index is `axis_chunk >> scale::CHUNK_DISTRICT_SHIFT` (arithmetic /// shift = floor division, correct for negative chunks) and `seed_bits` comes from /// the shared district-scale seed. Integer arithmetic (D-010). fn derive_district_anchor(axis_chunk: i32, seed_bits: u64) -> i32 { let district_idx = axis_chunk >> scale::CHUNK_DISTRICT_SHIFT; let origin_m = district_idx * scale::DISTRICT_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. /// Integer-only (D-010). #[inline] pub(crate) fn pos_to_id(pos: (i32, i32)) -> u64 { let zz = |v: i32| -> u64 { let v = v as i64; ((v << 1) ^ (v >> 63)) as u64 }; let x = zz(pos.0); let y = zz(pos.1); let s = x.wrapping_add(y); s.wrapping_mul(s.wrapping_add(1)) .wrapping_div(2) .wrapping_add(y) } /// Derive basin direction from the district profile and a district-scale seed. /// /// Uses `elev_q` and `slope_q` as a proxy for the dominant D8 gradient /// direction. In lieu of a full D8 computation at this scale, the basin /// direction is derived from the district's terrain characteristics: /// - Coastal districts (high `ocean_fraction_q`) flow toward ocean (West fallback) /// - High-elevation districts flow away from ridges (seed-derived direction) /// - Low-slope districts use the seed for unbiased direction /// /// All integer arithmetic (D-010). fn derive_basin_direction(district: &DistrictProfile, district_seed: u64) -> BasinDirection { // Coastal: flow toward the ocean (use seed to pick E/W/N/S with coastal bias). if district.ocean_fraction_q >= 15 { // The low 2 bits of seed give 4 directions; bias toward the most common // coastal configurations (N or S for equatorial coasts, E/W for shelf). return match (district_seed >> 2) & 0x3 { 0 => BasinDirection::South, 1 => BasinDirection::East, 2 => BasinDirection::North, _ => BasinDirection::West, }; } // Interior: pure seed-derived direction (unbiased). match district_seed & 0x3 { 0 => BasinDirection::North, 1 => BasinDirection::East, 2 => BasinDirection::South, _ => BasinDirection::West, } } /// Derive meander wavelength in metres from district morphology. /// /// Low slope + high moisture → longer wavelength (wide meanders). /// High slope → short wavelength (confined/straight channels). /// Result is f64 positional physics (D-239 §4), not used in gate comparisons. fn derive_meander_wavelength(district: &DistrictProfile) -> f64 { // Base wavelength range: 80–500 m for AlluvialPlain. // slope_q 0 → 500 m; slope_q 100 → 80 m. Linear interpolation. let slope_clamped = district.slope_q.clamp(0, 100) as f64; let base = 500.0 - (slope_clamped / 100.0) * 420.0; // Moisture boost: high moisture → slightly longer wavelength (more sinuous). let moisture_factor = 1.0 + (district.moisture_q.clamp(0, 100) as f64 / 100.0) * 0.3; base * moisture_factor } /// Derive active channel width in metres from district morphology. /// /// Returns an integer metre value (D-010). fn derive_channel_width(district: &DistrictProfile) -> i32 { // Water presence drives width; ocean_fraction_q is our proxy. // Meander channels: 3–15 m (D-239 §9 game-feel constraint). // We derive in that range from ocean_fraction_q. let base = match district.ocean_fraction_q { 0..=9 => 3, 10..=19 => 5, 20..=34 => 8, 35..=49 => 10, _ => 15, }; // Slope modifier: high slope → narrower (gorge-like); low slope → wider. let slope_penalty = (district.slope_q / 20).min(3); (base - slope_penalty).max(3) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::atlas::district_profile::{ GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass, }; use crate::simulation::generator::MorphologyZone; fn alluvial_district() -> DistrictProfile { DistrictProfile { morphology_zone: MorphologyZone::AlluvialPlain, tectonic_class: TectonicClass::Stable, glaciation_grade: GlaciationGrade::None, precipitation_class: PrecipitationClass::Temperate, slope_q: 5, elev_q: 20, ocean_fraction_q: 15, river_threshold: 200, temperature_c: Some(18.0), moisture_q: 55, vegetation_class: VegetationClass::Forest, } } /// The chunk of district (0, 0) whose cross-range contains the district's /// channel anchor — guaranteed inside the T-1040 channel band. fn anchor_chunk_pos(world_seed: u64, body_id: &str, district: &DistrictProfile) -> ChunkPos { let probe = derive_chunk_context(world_seed, body_id, district, (0, 0), None); 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 district = alluvial_district(); let a = derive_chunk_context(42, "GJ1c", &district, (10, 20), None); let b = derive_chunk_context(42, "GJ1c", &district, (10, 20), None); assert_eq!(a.basin_direction, b.basin_direction); assert_eq!(a.meander_phase, b.meander_phase); assert_eq!(a.meander_wavelength_m, b.meander_wavelength_m); assert_eq!(a.has_active_channel, b.has_active_channel); assert_eq!(a.channel_width_m, b.channel_width_m); } #[test] fn different_positions_yield_different_phases() { let district = alluvial_district(); let a = derive_chunk_context(42, "GJ1c", &district, (0, 0), None); let b = derive_chunk_context(42, "GJ1c", &district, (200, 100), None); // Different district-scale ids → different phases (high probability). assert_ne!( a.meander_phase, b.meander_phase, "chunks far apart should differ in meander phase" ); } #[test] fn alluvial_district_has_active_channel() { let district = alluvial_district(); // T-1040: the channel is district-anchored — the chunk under the anchor // must claim it (ocean_fraction_q=15 → water present). let pos = anchor_chunk_pos(42, "GJ1c", &district); let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None); assert!( ctx.has_active_channel, "anchor-covering chunk of a watered district must have active channel" ); } #[test] fn chunk_outside_channel_band_has_no_active_channel() { // T-1040: channels are district-anchored, not district-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 district did, while voxels rendered dry floodplain. let district = alluvial_district(); let (anchor_pos, probe) = { let pos = anchor_chunk_pos(42, "GJ1c", &district); (pos, derive_chunk_context(42, "GJ1c", &district, pos, None)) }; // 8 cross-chunks away (512 m) is past any band reach but still inside // district (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", &district, far_pos, None); 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" ); // District-scale params stay constant across the district'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 district property — identical for // every chunk of the district, and positioned inside the district's extent. let district = alluvial_district(); let base = derive_chunk_context(42, "GJ1c", &district, (0, 0), None); for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] { let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None); assert_eq!( ctx.channel_anchor_m, base.channel_anchor_m, "channel anchor must be district-constant (chunk {pos:?})" ); assert_eq!( ctx.coast_anchor_m, base.coast_anchor_m, "coast anchor must be district-constant (chunk {pos:?})" ); } // District (0, 0) spans [0, scale::DISTRICT_M) m on both axes. assert!((0..scale::DISTRICT_M).contains(&base.channel_anchor_m)); assert!((0..scale::DISTRICT_M).contains(&base.coast_anchor_m)); // A different district derives its anchors inside its own extent. Chunk // (1000, -750) → district (1000 >> 5, -750 >> 5) = (31, -24); the cross // axis (and thus which district index the channel anchor sits in) depends // on the basin direction. let far = derive_chunk_context(42, "GJ1c", &district, (1000, -750), None); let dm = scale::DISTRICT_M; assert!( (31 * dm..32 * dm).contains(&far.channel_anchor_m) || (-24 * dm..-23 * dm).contains(&far.channel_anchor_m), "far district anchor {} must lie inside its district extent (cross axis depends on basin direction)", far.channel_anchor_m ); } #[test] fn dry_region_has_no_active_channel() { let district = DistrictProfile { morphology_zone: MorphologyZone::AlluvialPlain, tectonic_class: TectonicClass::Stable, glaciation_grade: GlaciationGrade::None, precipitation_class: PrecipitationClass::Arid, slope_q: 3, elev_q: 10, ocean_fraction_q: 0, // no water at all river_threshold: 300, temperature_c: Some(35.0), moisture_q: 5, vegetation_class: VegetationClass::Barren, }; let ctx = derive_chunk_context(42, "dry_body", &district, (5, 5), None); assert!( !ctx.has_active_channel, "arid district with ocean_fraction_q=0 must not have active channel" ); } #[test] fn channel_width_in_game_feel_range() { // D-239 §9: river crossings 3–15 m. Measured on a chunk that carries // the channel (T-1040 gating zeroes the width elsewhere). let district = alluvial_district(); let pos = anchor_chunk_pos(42, "GJ1c", &district); let ctx = derive_chunk_context(42, "GJ1c", &district, pos, None); assert!( (3..=15).contains(&ctx.channel_width_m), "channel_width_m {} out of game-feel range [3, 15]", ctx.channel_width_m ); } #[test] fn meander_wavelength_within_physics_range() { let district = alluvial_district(); let ctx = derive_chunk_context(42, "GJ1c", &district, (5, 5), None); // AlluvialPlain flat (slope_q=5): should be near max wavelength. assert!( ctx.meander_wavelength_m > 400.0 && ctx.meander_wavelength_m < 700.0, "meander wavelength {} out of expected range for flat alluvial", ctx.meander_wavelength_m ); } #[test] fn basin_direction_discriminants_pinned() { // Append-only invariant (D-010). assert_eq!(BasinDirection::North as u8, 0); assert_eq!(BasinDirection::East as u8, 1); assert_eq!(BasinDirection::South as u8, 2); assert_eq!(BasinDirection::West as u8, 3); } #[test] fn pos_to_id_is_injective_for_small_coords() { // Spot-check that nearby positions produce distinct ids. let ids: Vec = [(0, 0), (1, 0), (0, 1), (1, 1), (-1, 0), (0, -1), (-1, -1)] .iter() .map(|&p| pos_to_id(p)) .collect(); let unique: std::collections::BTreeSet = ids.iter().copied().collect(); assert_eq!(ids.len(), unique.len(), "pos_to_id must be injective"); } }