Replaces the single-DistrictProfile walking-skeleton restriction so continuous terrain parameters no longer step at the 2 km district pitch (D-239 §4/§7/§8). ChunkContext gains secondary: Option<DistrictProfile> + blend_weight: u8; derive_chunk_context detects a chunk within one chunk of a district border, looks up the adjacent profile, and integer-blends meander/channel params on the context. derive_voxel_column integer-blends elev_q/moisture_q before material selection, reusing the same warp offset so the seam cannot align with the chunk edge (Cow fast-path keeps interior chunks bit-identical — golden seed unchanged). Morphology FAMILY selection is never blended — stays sharp per D-239 §7 (family dispatch reads the primary district only). Climate feathering is the separate T-1078 path. Adds cross_district_elevation_blend_reduces_seam_step and cross_district_morphology_family_seams_stay_sharp to the derivation harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
729 lines
34 KiB
Rust
729 lines
34 KiB
Rust
//! 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<DistrictProfile>,
|
||
|
||
/// 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<u64> = [(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<u64> = ids.iter().copied().collect();
|
||
assert_eq!(ids.len(), unique.len(), "pos_to_id must be injective");
|
||
}
|
||
}
|