Files
settled-reach/server/src/atlas/chunk_context.rs
T
jpmschweitzerandClaude Opus 4.8 32010cc498 feat(simulation): cross-district parameter blending at chunk/voxel scale (T-1042)
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>
2026-06-15 14:05:48 +02:00

729 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (0255) 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 (0255).
/// 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 80500 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. 0255.
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: 80500 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: 315 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 315 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");
}
}