From cd8bfec64bd72dc8f511e07c0c933af2d1f5df9f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 13:53:47 +0200 Subject: [PATCH] feat(simulation): VoxelColumn walking skeleton (T-1028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-239 §1/§7/§10 — the lower derivation chain end-to-end with the AlluvialPlain fallback only (specialized families are T-1029). - chunk_context.rs: ChunkContext (64m) — basin_direction + meander phase/wavelength + active-channel params. NO per-tile flow_direction[64x64] (D8 ~152m coarser than a chunk, D-239 §10); features >64m seed from Region-or-higher. SeedDomain::ChunkContext=8. - voxel.rs: VoxelColumn (1m) composing the D-228 tile axes (TerrainMaterial / FloorMaterial=None / Vegetation / Water / elevation_m). Derive-on-demand + VoxelCache (LRU BTreeMap, never persisted per D-227). Domain warp (T-1026) wired through: f64 warp -> as-i32 truncation to the voxel address; all material/morphology gates integer (D-010 — the meander wavelength f64 is truncated before the Water decision). - AlluvialPlain generator: Soil/Wetland terrain, climate vegetation, integer-meander channel (Shallow/Deep) from ChunkContext. The other 7 families are dispatch stubs (canonical terrain material, fall back to AlluvialPlain geometry) — no panic. 29 new tests incl end-to-end determinism (same seed/pos -> identical column), cache hit/miss/eviction/re-derivation, warp-applied. cargo test 1563 pass, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/chunk_context.rs | 397 ++++++++++ server/src/atlas/mod.rs | 5 +- server/src/atlas/voxel.rs | 1125 +++++++++++++++++++++++++++++ server/src/seed.rs | 4 + 4 files changed, 1528 insertions(+), 3 deletions(-) create mode 100644 server/src/atlas/chunk_context.rs create mode 100644 server/src/atlas/voxel.rs diff --git a/server/src/atlas/chunk_context.rs b/server/src/atlas/chunk_context.rs new file mode 100644 index 000000000..27dbd0146 --- /dev/null +++ b/server/src/atlas/chunk_context.rs @@ -0,0 +1,397 @@ +//! 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 +//! region (~1 km). It is derived purely from the covering `RegionProfile`(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 region. +//! - **`meander_phase`** and **`meander_wavelength_m`** — global meander-curve +//! params for the MeanderReach and AlluvialPlain families. +//! +//! Features with wavelength > 64 m seed from **Region-or-higher** (the covering +//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure: +//! the chunk seed is only used for sub-chunk (<64 m) scatter. +//! +//! ## D-010 compliance +//! +//! All derivation is integer arithmetic. The only f64 in this module is +//! `meander_wavelength_m` (positional physics, not a gate comparison), consistent +//! with D-239 §4 ("the warp is position math, not a structural decision"). +//! +//! ## SeedDomain extension +//! +//! Uses `SeedDomain::ChunkContext = 8` (appended, never renumber). + +use serde::{Deserialize, Serialize}; + +use crate::atlas::region_profile::RegionProfile; +use crate::seed::{SeedChain, SeedDomain}; + +// --------------------------------------------------------------------------- +// Basin direction (cardinal, 4-way) +// --------------------------------------------------------------------------- + +/// Cardinal basin-flow direction — the dominant D8 thalweg direction in the +/// covering region. Coarser than a chunk (D-239 §10); derived from region 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 +// --------------------------------------------------------------------------- + +/// Position of a chunk on the 64 m grid, in chunk-units (not metres). +/// +/// A body at ~6 000 regions, each region ~1 km², gets ~1 000 × 1 000 chunks. +/// `BTreeMap` key — implements `Ord` for D-010 determinism. +pub type ChunkPos = (i32, i32); + +// --------------------------------------------------------------------------- +// ChunkContext +// --------------------------------------------------------------------------- + +/// 64 m carrier derived from `RegionProfile`(s) — the second tier of D-239 §1. +/// +/// Pure deterministic function of `(seed, body_id, region, 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. +/// - `meander_wavelength_m` — meander wavelength in metres. Derived from +/// region-level morphology (slope, moisture), seeded at region scale (> 64 m). +/// f64 for positional physics (D-239 §4); not used in any gate comparison. +/// - `has_active_channel` — whether a water channel is present in this chunk, +/// derived from the region's `ocean_fraction_q` and `river_threshold`. +/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no +/// active channel. +#[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 region scale (wavelength > 64 m), NOT from the chunk seed. + pub meander_phase: u8, + + /// Meander wavelength in metres. Positional physics value (f64 — D-239 §4). + /// Derived at region scale; typically 80–500 m for AlluvialPlain. + pub meander_wavelength_m: f64, + + /// Whether this chunk contains an active water channel. + /// True when the region's `ocean_fraction_q` × `river_threshold` signal + /// indicates a perennial waterway crosses this chunk. + pub has_active_channel: bool, + + /// Active channel width in metres (integer; D-010). 0 if no active channel. + /// Derived from region morphology and slope. + pub channel_width_m: i32, +} + +// --------------------------------------------------------------------------- +// Derivation +// --------------------------------------------------------------------------- + +/// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid. +/// +/// Pure function of `(seed, body_id, region, chunk_pos)`. Takes the covering +/// region's `RegionProfile`; in the future a blend of adjacent profiles will +/// handle cross-region chunk seams, but for the walking skeleton one profile +/// is sufficient. +/// +/// ## Seed usage +/// +/// The **meander phase** and **basin direction** are seeded at region 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, + region: &RegionProfile, + chunk_pos: ChunkPos, +) -> ChunkContext { + // Region-scale seed — features with wavelength > 64 m derive from here. + // Keyed on the chunk position mapped to region-scale units (>> 4 gives + // the ~1 km region index if a region is ~16 chunks wide). + let region_scale_id = pos_to_id((chunk_pos.0 >> 4, chunk_pos.1 >> 4)); + let region_seed = + SeedChain::for_body(world_seed, body_id).derive(SeedDomain::ChunkContext, region_scale_id); + + // Basin direction — derived from region slope_q (which encodes the + // dominant terrain gradient). We use the region's `elev_q` gradient + // direction as a proxy for the D8 thalweg direction. + // All integer arithmetic (D-010). + let basin_direction = derive_basin_direction(region, region_seed.seed()); + + // Meander phase — region-scale integer offset so the channel is consistent + // across all chunks in the same region. 0–255. + let meander_phase = (region_seed.seed() >> 8) as u8; + + // Meander wavelength — derived from slope and morphology, region-scale. + // Lower slope → longer wavelength (wider meanders); integer inputs, f64 result + // is positional physics (D-239 §4, not a gate comparison). + let meander_wavelength_m = derive_meander_wavelength(region); + + // Active channel — present when the region has meaningful water presence + // (ocean_fraction_q >= 10 indicates a perennial waterway or water body + // covers at least 10% of the region). The river_threshold governs D8 + // drainage accumulation at a finer scale; at the chunk level we use + // ocean_fraction_q as the direct proxy for water presence (D-239 §10: + // the chunk carries one basin-direction, not a per-tile flow grid). + let has_active_channel = region.ocean_fraction_q >= 10; + + // Channel width — derived from ocean_fraction_q (proxy for water presence + // at region scale); integer metres; 0 when no active channel. + let channel_width_m = if has_active_channel { + derive_channel_width(region) + } else { + 0 + }; + + ChunkContext { + basin_direction, + meander_phase, + meander_wavelength_m, + has_active_channel, + channel_width_m, + } +} + +/// 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 region profile and a region-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 region's terrain characteristics: +/// - Coastal regions (high `ocean_fraction_q`) flow toward ocean (West fallback) +/// - High-elevation regions flow away from ridges (seed-derived direction) +/// - Low-slope regions use the seed for unbiased direction +/// +/// All integer arithmetic (D-010). +fn derive_basin_direction(region: &RegionProfile, region_seed: u64) -> BasinDirection { + // Coastal: flow toward the ocean (use seed to pick E/W/N/S with coastal bias). + if region.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 (region_seed >> 2) & 0x3 { + 0 => BasinDirection::South, + 1 => BasinDirection::East, + 2 => BasinDirection::North, + _ => BasinDirection::West, + }; + } + + // Interior: pure seed-derived direction (unbiased). + match region_seed & 0x3 { + 0 => BasinDirection::North, + 1 => BasinDirection::East, + 2 => BasinDirection::South, + _ => BasinDirection::West, + } +} + +/// Derive meander wavelength in metres from region 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(region: &RegionProfile) -> f64 { + // Base wavelength range: 80–500 m for AlluvialPlain. + // slope_q 0 → 500 m; slope_q 100 → 80 m. Linear interpolation. + let slope_clamped = region.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 + (region.moisture_q.clamp(0, 100) as f64 / 100.0) * 0.3; + + base * moisture_factor +} + +/// Derive active channel width in metres from region morphology. +/// +/// Returns an integer metre value (D-010). +fn derive_channel_width(region: &RegionProfile) -> 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 region.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 = (region.slope_q / 20).min(3); + (base - slope_penalty).max(3) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::region_profile::{ + GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass, + }; + use crate::simulation::generator::MorphologyZone; + + fn alluvial_region() -> RegionProfile { + RegionProfile { + 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, + } + } + + #[test] + fn derive_chunk_context_is_deterministic() { + let region = alluvial_region(); + let a = derive_chunk_context(42, "GJ1c", ®ion, (10, 20)); + let b = derive_chunk_context(42, "GJ1c", ®ion, (10, 20)); + 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 region = alluvial_region(); + let a = derive_chunk_context(42, "GJ1c", ®ion, (0, 0)); + let b = derive_chunk_context(42, "GJ1c", ®ion, (200, 100)); + // Different region-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_region_has_active_channel() { + let region = alluvial_region(); + let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5)); + // ocean_fraction_q=15 + temperate precip → should have active channel. + assert!( + ctx.has_active_channel, + "alluvial plain with ocean_fraction_q=15 should have active channel" + ); + } + + #[test] + fn dry_region_has_no_active_channel() { + let region = RegionProfile { + 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", ®ion, (5, 5)); + assert!( + !ctx.has_active_channel, + "arid region 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. + let region = alluvial_region(); + let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5)); + 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 region = alluvial_region(); + let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5)); + // 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"); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index fe195e0dd..203959cd9 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -8,11 +8,9 @@ pub mod block_irregularity; pub mod body_params_reader; pub mod body_world_state; pub mod cascade; +pub mod chunk_context; pub mod city_context_reader; pub mod district_mix; -// T-1026 foundation utility: covered by unit tests but not yet called from -// production — its consumer is the T-1028 VoxelColumn pipeline (D-239 §4). -#[allow(dead_code)] pub mod domain_warp; pub mod drainage; pub mod features; @@ -26,5 +24,6 @@ pub mod skeleton_gen; pub mod source_resolver; pub mod subbiome; pub mod tile_condition; +pub mod voxel; pub use plugin::GenerationPlugin; diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs new file mode 100644 index 000000000..9ea0f2b96 --- /dev/null +++ b/server/src/atlas/voxel.rs @@ -0,0 +1,1125 @@ +//! VoxelColumn — 1 m derivation tier of the D-239 refinement chain (T-1028). +//! +//! A `VoxelColumn` is the finest derivation tier: derived on-demand from a +//! `ChunkContext` (64 m) and the covering `RegionProfile` (~1 km). Never stored, +//! never persisted — cached in `VoxelCache` (D-227). +//! +//! ## D-228 composite tile axes +//! +//! Each `VoxelColumn` is a bundle of **orthogonal axes** (D-228): +//! - `terrain`: `TerrainMaterial` — permanent natural ground (Soil/Sand/Gravel/Rock/Wetland/Lava) +//! - `floor`: `FloorMaterial` — built surface (None for now; Phase 5) +//! - `vegetation`: `Vegetation` — ground cover (from `VegetationClass`) +//! - `water`: `Water` — local depth state (Dry/Shallow/Deep) +//! - `elevation_m`: scalar metres +//! +//! **Snow/Ice are NOT `TerrainMaterial`** — they are a seasonal cover overlay +//! (Q-105), out of scope here. +//! +//! ## Domain warp (D-239 §4) +//! +//! The warp is applied **before** the integer voxel address is computed. The +//! caller passes the logical tile position `(tile_x, tile_y)` in metres; the +//! derivation function calls `domain_warp(seed, body_id, (tile_x, tile_y))` to +//! get `(dx, dy)` in f64, then truncates to the integer voxel address with +//! `as i32` (cast, not comparison — IEEE-754 deterministic, D-239 §4). +//! +//! The warp is **the only f64** in the structural derivation path; all +//! material/morphology decisions downstream operate on the integer voxel address. +//! +//! ## Cache (D-227) +//! +//! `VoxelCache` is a bounded LRU-style cache backed by a `BTreeMap` and a +//! monotonic generation counter. Same (seed, body, pos) → same column; +//! eviction is by generation-counter LRU (oldest entry evicted when capacity +//! is reached). Never written to disk. +//! +//! ## Family dispatch (D-239 §5, T-1028) +//! +//! The 8-family tree is dispatched through `MorphologyFamily`. Only +//! `AlluvialPlain` is implemented here (T-1028). The other 7 families are +//! T-1029: their stubs return the AlluvialPlain output as a documented +//! placeholder — **production will not crash on them**, but the output is not +//! the correct final geometry for that family. The stubs are clearly marked +//! `// T-1029 — NOT YET IMPLEMENTED` so T-1029 can find and replace them. +//! +//! ## D-010 compliance +//! +//! All material/morphology decisions are integer. The only f64 path is the +//! domain warp displacement (positional math, truncated before any decision). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::atlas::chunk_context::ChunkContext; +use crate::atlas::domain_warp::domain_warp; +use crate::atlas::region_profile::{RegionProfile, VegetationClass}; +use crate::seed::{SeedChain, SeedDomain}; +use crate::simulation::generator::MorphologyZone; + +// --------------------------------------------------------------------------- +// D-228 tile axes +// --------------------------------------------------------------------------- + +/// Permanent natural ground material (D-228). +/// +/// **Snow and Ice are NOT here** — they are a seasonal cover overlay (Q-105). +/// Integer-discriminant, append-only (D-010). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum TerrainMaterial { + /// Loamy soil — rolling/floodplain (D-239 §8 Soil law). + #[default] + Soil = 0, + /// Sand — ≤~32° angle of repose, dunes not cliffs (D-239 §8 Sand law). + Sand = 1, + /// Gravel — braided channels/fans, not single-thread meander (D-239 §8). + Gravel = 2, + /// Rock — vertical faces on steep steps (D-239 §8 Rock law). + Rock = 3, + /// Wetland substrate — saturated organic soil, ≤5° flats (D-239 §8). + Wetland = 4, + /// Lava — sheets/shield slopes + tubes, immature drainage (D-239 §8). + Lava = 5, +} + +/// Built surface over natural ground (D-228). +/// +/// `None` is the only implemented value for Phase 4 — built surfaces are +/// Phase 5 (player control). The vocabulary is declared here so the type system +/// enforces the distinction from `TerrainMaterial`. +/// +/// Integer-discriminant, append-only (D-010). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum FloorMaterial { + /// No built surface — natural ground exposed. + #[default] + None = 0, + // Phase 5 variants: Concrete = 1, Pavement = 2, Carpet = 3, Metal = 4, … +} + +/// Ground cover (D-228). Carries cover/concealment, movement sound profile, +/// and economic yield (timber/crops). +/// +/// Derived from the region's `VegetationClass` (D-239 §8 climate→vegetation law). +/// Integer-discriminant, append-only (D-010). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum Vegetation { + /// No vegetation — above treeline or hyper-arid. + #[default] + Barren = 0, + /// Grass — open ground cover, low concealment. + Grass = 1, + /// Scrub — transitional shrubs, moderate concealment. + Scrub = 2, + /// Thicket — dense low cover, high concealment. + Thicket = 3, + /// Forest — closed canopy, full concealment. + Forest = 4, + /// Crop — managed agriculture (Phase 6 economic layer). + Crop = 5, + /// Cleared — formerly vegetated, stripped (player/sim action). + Cleared = 6, +} + +impl Vegetation { + /// Map a region-level `VegetationClass` to the per-voxel `Vegetation` axis. + /// + /// The region class establishes the dominant cover; per-voxel scatter + /// (Grass vs Scrub at the margin) is handled by the voxel generator using + /// the sub-chunk seed. This function gives the canonical deterministic + /// baseline before scatter is applied. + pub fn from_vegetation_class(vc: VegetationClass) -> Self { + match vc { + VegetationClass::Absent => Vegetation::Barren, + VegetationClass::Barren => Vegetation::Barren, + VegetationClass::Scrub => Vegetation::Scrub, + VegetationClass::Forest => Vegetation::Forest, + VegetationClass::RiparianScrub => Vegetation::Thicket, + VegetationClass::RiparianThicket => Vegetation::Thicket, + } + } +} + +/// Local depth state (D-228). +/// +/// `Water` is dynamic — changes with season/tide (Q-105) — but for Phase 4 +/// derivation we use the static base state (the seasonal overlay is deferred). +/// +/// Integer-discriminant, append-only (D-010). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[repr(u8)] +pub enum Water { + /// No standing water. + #[default] + Dry = 0, + /// Shallow water — passable with effort; grants concealment penalty. + Shallow = 1, + /// Deep water — impassable without vessel; full concealment penalty. + Deep = 2, +} + +// --------------------------------------------------------------------------- +// VoxelColumn +// --------------------------------------------------------------------------- + +/// The finest derivation tier — the D-228 composite tile axes for one 1 m column. +/// +/// Derived on demand from `(seed, body_id, region, chunk_context, voxel_pos)`. +/// Never stored, never persisted. Cached in `VoxelCache` (D-227). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VoxelColumn { + /// Permanent natural ground material (D-228). + pub terrain: TerrainMaterial, + /// Built surface over the ground (D-228). `None` in Phase 4. + pub floor: FloorMaterial, + /// Ground cover (D-228). Derived from region `VegetationClass`. + pub vegetation: Vegetation, + /// Local depth state (D-228). + pub water: Water, + /// Surface elevation in integer metres (D-010; no f64 in the stored value). + pub elevation_m: i32, +} + +// --------------------------------------------------------------------------- +// Voxel position (integer metres, D-010) +// --------------------------------------------------------------------------- + +/// Absolute voxel position — integer metres. The unit coordinate of D-222. +/// +/// After domain warp is applied (`tile_x + dx as i32`), the result is the +/// integer voxel address used for all material/morphology decisions (D-239 §4). +pub type VoxelPos = (i32, i32); + +// --------------------------------------------------------------------------- +// Family dispatch (D-239 §5) +// --------------------------------------------------------------------------- + +/// Internal family identifier for the 8-family morphology dispatch (D-239 §5). +/// +/// Derived from `MorphologyZone` by `zone_to_family`. The dispatch gates are +/// pre-computed at zone classification time (RegionProfile); the family is the +/// structural decision that drives voxel geometry. +/// +/// Only `AlluvialPlain` is implemented in T-1028. Other families return a +/// documented placeholder that falls back to AlluvialPlain geometry (not +/// `panic!` / `unimplemented!`). T-1029 replaces the stubs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MorphologyFamily { + /// Lava field / shield slope. Requires TectonicClass::Volcanic (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + LavaField, + /// Fjord wall. Requires GlaciationGrade ≥ 2 (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + FjordWall, + /// Cliff coast. High slope + coastal (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + CliffCoast, + /// Braided delta. Very flat + low elevation + coastal (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + BraidedDelta, + /// Dune strand. Low slope + coastal + arid (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + DuneStrand, + /// Incised gorge / mountain pass. High slope + inland + high elev (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + IncisedGorge, + /// Meander reach. Gentle slope + water presence (D-239 §5). + /// T-1029 — NOT YET IMPLEMENTED: falls back to AlluvialPlain. + MeanderReach, + /// Alluvial plain — fallback (D-239 §5). **IMPLEMENTED** in T-1028. + AlluvialPlain, +} + +/// Map a `MorphologyZone` to the 8-family `MorphologyFamily` dispatch key. +/// +/// Sub-classified zones (TidalFlat, Estuarine, Alpine, Wetland) map to their +/// parent family for voxel geometry — their distinction is captured in the +/// region zone label, not in the voxel generator dispatch. +/// +/// BraidedPlain maps to BraidedDelta (its parent at region scale, per the +/// D-239 §6 implementation note — BraidedPlain is deferred from RegionProfile +/// to ChunkContext sub-classification). +fn zone_to_family(zone: &MorphologyZone) -> MorphologyFamily { + match zone { + MorphologyZone::Volcanic => MorphologyFamily::LavaField, + MorphologyZone::Fjord => MorphologyFamily::FjordWall, + MorphologyZone::CliffCoast => MorphologyFamily::CliffCoast, + MorphologyZone::Delta | MorphologyZone::BraidedPlain | MorphologyZone::Estuarine => { + MorphologyFamily::BraidedDelta + } + MorphologyZone::DuneStrand => MorphologyFamily::DuneStrand, + MorphologyZone::MountainPass | MorphologyZone::ValleyFloor | MorphologyZone::Alpine => { + MorphologyFamily::IncisedGorge + } + MorphologyZone::MeanderReach | MorphologyZone::RiverBank => MorphologyFamily::MeanderReach, + // AlluvialPlain fallback covers: AlluvialPlain, OpenOcean, Lake, TidalFlat, + // Wetland — any zone without a fully-implemented T-1029 generator. + MorphologyZone::AlluvialPlain + | MorphologyZone::OpenOcean + | MorphologyZone::Lake + | MorphologyZone::TidalFlat + | MorphologyZone::Wetland => MorphologyFamily::AlluvialPlain, + } +} + +// --------------------------------------------------------------------------- +// Voxel derivation entry point +// --------------------------------------------------------------------------- + +/// Derive a `VoxelColumn` for the tile at integer metre position `(tile_x, tile_y)`. +/// +/// ## Warp wiring (D-239 §4) +/// +/// 1. Call `domain_warp(seed, body_id, (tile_x, tile_y))` → `(dx, dy)` in f64. +/// 2. Apply `voxel_x = (tile_x as f64 + dx) as i32` (and likewise for `voxel_y`). +/// 3. The integer `(voxel_x, voxel_y)` is used for all downstream decisions. +/// This is the **only** f64 in the structural path. +/// +/// ## Parameters +/// +/// - `world_seed` — master world seed. +/// - `body_id` — stable string body identifier (used for domain separation). +/// - `region` — the covering `RegionProfile` (~1 km). +/// - `chunk` — the covering `ChunkContext` (64 m). +/// - `tile_x`, `tile_y` — logical tile position in **integer metres** (D-010). +/// +/// ## Returns +/// +/// A fully derived `VoxelColumn` with all D-228 axes populated. +pub fn derive_voxel_column( + world_seed: u64, + body_id: &str, + region: &RegionProfile, + chunk: &ChunkContext, + tile_x: i32, + tile_y: i32, +) -> VoxelColumn { + // ── 1. Domain warp (D-239 §4) ───────────────────────────────────────── + // Apply warp and truncate to integer voxel address. This is the ONLY f64 + // in the structural path; all decisions below use integer voxel coords. + let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y)); + let voxel_x = (tile_x as f64 + dx) as i32; + let voxel_y = (tile_y as f64 + dy) as i32; + let voxel_pos: VoxelPos = (voxel_x, voxel_y); + + // ── 2. Sub-chunk seed (for features with wavelength < 64 m) ─────────── + // Keyed on integer voxel address after warp — deterministic (D-010). + let sub_chunk_seed = SeedChain::for_body(world_seed, body_id) + .derive(SeedDomain::ChunkContext, voxel_pos_to_id(voxel_pos)) + .seed(); + + // ── 3. Family dispatch (D-239 §5) ───────────────────────────────────── + let family = zone_to_family(®ion.morphology_zone); + + match family { + MorphologyFamily::AlluvialPlain => { + generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) + } + + // ── T-1029 stubs: NOT YET IMPLEMENTED ──────────────────────────── + // Each stub falls back to AlluvialPlain geometry so production does + // not crash on them. T-1029 replaces these one family at a time. + // The comment "T-1029 — NOT YET IMPLEMENTED" is the search target. + + // T-1029 — NOT YET IMPLEMENTED: LavaField + // LavaField geometry: lava sheets + shield slopes + tubes. + // For now: AlluvialPlain fallback with Lava terrain material. + MorphologyFamily::LavaField => { + let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); + col.terrain = TerrainMaterial::Lava; + col.vegetation = Vegetation::Barren; // Lava surfaces are barren. + col + } + + // T-1029 — NOT YET IMPLEMENTED: FjordWall + // FjordWall geometry: U-valley walls, deep water inlets, ribbon geometry. + MorphologyFamily::FjordWall => { + generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) + } + + // T-1029 — NOT YET IMPLEMENTED: CliffCoast + // CliffCoast geometry: vertical Rock faces at water edge. + MorphologyFamily::CliffCoast => { + let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); + col.terrain = TerrainMaterial::Rock; + col + } + + // T-1029 — NOT YET IMPLEMENTED: BraidedDelta + // BraidedDelta geometry: Gravel substrate, braided channels, bridges as forced nodes. + MorphologyFamily::BraidedDelta => { + let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); + col.terrain = TerrainMaterial::Gravel; + col + } + + // T-1029 — NOT YET IMPLEMENTED: DuneStrand + // DuneStrand geometry: Sand substrate, ≤32° angle of repose, wind-aligned. + MorphologyFamily::DuneStrand => { + let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); + col.terrain = TerrainMaterial::Sand; + col.vegetation = Vegetation::Barren; // Dunes are mostly barren. + col + } + + // T-1029 — NOT YET IMPLEMENTED: IncisedGorge + // IncisedGorge geometry: Rock walls, narrow floor (2–8 m), high elevation steps. + MorphologyFamily::IncisedGorge => { + let mut col = generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed); + col.terrain = TerrainMaterial::Rock; + col + } + + // T-1029 — NOT YET IMPLEMENTED: MeanderReach + // MeanderReach geometry: Soil, sinuous channel, levees, D-239 §9 ElevationDelta. + MorphologyFamily::MeanderReach => { + generate_alluvial_plain(region, chunk, voxel_pos, sub_chunk_seed) + } + } +} + +// --------------------------------------------------------------------------- +// AlluvialPlain generator (T-1028, D-239 §5 fallback) +// --------------------------------------------------------------------------- + +/// AlluvialPlain voxel generator — the only fully implemented family in T-1028. +/// +/// Flat floodplain with: +/// - `Soil` terrain material (D-239 §8 Soil law: rolling/floodplain) +/// - Vegetation from the region's `vegetation_class` (D-239 §2) +/// - Flat elevation derived from region `elev_q` + small local scatter +/// - Meander channel cut from `ChunkContext` (D-239 §10: one basin direction +/// + global meander params, NOT a per-tile flow_direction grid) +/// - Water state: Shallow/Deep in channel, Dry elsewhere +/// +/// ## Meander channel placement +/// +/// The channel is placed by a sine-wave approximation centred on the chunk's +/// `basin_direction`. The perpendicular distance from the voxel to the wave +/// determines whether the voxel is in-channel. This avoids per-tile flow +/// grids while producing a spatially coherent channel (D-239 §10). +/// +/// Channel width is `chunk.channel_width_m` (game-feel range 3–15 m). +fn generate_alluvial_plain( + region: &RegionProfile, + chunk: &ChunkContext, + voxel_pos: VoxelPos, + sub_chunk_seed: u64, +) -> VoxelColumn { + // ── Terrain material ─────────────────────────────────────────────────── + // AlluvialPlain → Soil (D-239 §8 Soil law). Wetland sub-zone → Wetland + // substrate when moisture is very high (§8 Wetland ≤5° flats law). + let terrain = if matches!(region.morphology_zone, MorphologyZone::Wetland) + || (region.slope_q <= 5 && region.moisture_q >= 60) + { + TerrainMaterial::Wetland + } else { + TerrainMaterial::Soil + }; + + // ── Vegetation ───────────────────────────────────────────────────────── + // Base vegetation from region class; sub-chunk scatter (a few Grass/Scrub + // variations) uses `sub_chunk_seed` — wavelength < 64 m. + let base_veg = Vegetation::from_vegetation_class(region.vegetation_class); + let vegetation = scatter_vegetation(base_veg, sub_chunk_seed); + + // ── Base elevation ────────────────────────────────────────────────────── + // Convert region `elev_q` (0–100) to metres. We scale 0–100 → 0–50 m here + // (walking-skeleton fidelity: a rough body-relative elevation). + // Sub-chunk micro-relief: small integer scatter ±2 m from the seed. + let base_elev_m = region.elev_q / 2; + let micro_relief = (sub_chunk_seed & 0x7) as i32 - 4; // [-4, +3] but we clamp + let elevation_m = (base_elev_m + micro_relief).max(0); + + // ── Channel (meander, D-239 §10) ─────────────────────────────────────── + // Check if this voxel falls within the meander channel. The channel is a + // sine-wave path through the chunk, parameterised by meander_phase and + // meander_wavelength_m. We compute the perpendicular distance from the voxel + // to the channel centreline; if it's within half the channel width, the voxel + // is in-channel. + // + // All comparison is integer (D-010): we compute a scaled integer distance. + let (in_channel, is_deep) = if chunk.has_active_channel { + compute_channel_state(voxel_pos, chunk, sub_chunk_seed) + } else { + (false, false) + }; + + let water = if is_deep { + Water::Deep + } else if in_channel { + Water::Shallow + } else { + Water::Dry + }; + + // ── Channel elevation adjustment ─────────────────────────────────────── + // In-channel voxels are slightly lower than the surrounding floodplain + // (the channel is cut below the levee/floodplain surface). This satisfies + // D-239 §9: "channels fall below and levees above the high-water threshold". + // We use a fixed 2 m cut (integer, D-010). + let elevation_m = if in_channel { + (elevation_m - 2).max(0) + } else { + elevation_m + }; + + VoxelColumn { + terrain, + floor: FloorMaterial::None, + vegetation, + water, + elevation_m, + } +} + +/// Determine if a voxel is in the meander channel, and if it is deep. +/// +/// The channel is a sine-wave path oriented along `chunk.basin_direction`. +/// We compute the perpendicular distance (integer, D-010) from the voxel to +/// the wave centreline. Within half the channel width → in-channel; within +/// one-third of the channel width → deep water. +/// +/// ## Integer arithmetic (D-010) +/// +/// We use a 10× fixed-point approximation for the sine: `sin(θ) ≈ phase_term / 10` +/// where `phase_term` is derived from the meander phase and the voxel's +/// along-channel coordinate. This avoids any float comparison while producing +/// a spatially coherent (though imprecise) meander curve. +fn compute_channel_state( + voxel_pos: VoxelPos, + chunk: &ChunkContext, + sub_chunk_seed: u64, +) -> (bool, bool) { + let (vx, vy) = voxel_pos; + + // Along-channel (parallel) and cross-channel (perpendicular) coordinate. + // basin_direction determines the primary axis. + let (along, cross) = match chunk.basin_direction { + crate::atlas::chunk_context::BasinDirection::North + | crate::atlas::chunk_context::BasinDirection::South => (vy, vx), + crate::atlas::chunk_context::BasinDirection::East + | crate::atlas::chunk_context::BasinDirection::West => (vx, vy), + }; + + // Meander wavelength in metres (integer approximation: truncate f64 → i32). + // The f64 wavelength is position math (D-239 §4), not a gate comparison — + // we truncate to integer here before any structural decision. + let wavelength_m = (chunk.meander_wavelength_m as i32).max(10); + + // Phase angle in [0, wavelength_m). Deterministic integer modulo. + let phase_offset = chunk.meander_phase as i32; + let angle_mod = (along + phase_offset).rem_euclid(wavelength_m); + + // Integer sine approximation: map angle_mod to a cross-channel displacement + // in [−amplitude, +amplitude]. We use a triangle wave (cheaper than a + // true sine table but still spatially coherent for a walking skeleton). + // + // Triangle wave on [0, wavelength_m]: + // - [0, wavelength_m/2]: displacement = 2*amplitude * angle / wavelength_m − amplitude + // - [wavelength_m/2, wavelength_m]: displacement = amplitude − 2*amplitude * (angle − wl/2) / wl + // + // Amplitude: ~15% of wavelength for a moderate sinuosity. + let amplitude = wavelength_m / 6; // ~17% sinuosity + let amplitude = amplitude.max(2); // at least ±2 m amplitude + let half_wl = wavelength_m / 2; + let meander_displacement = if angle_mod < half_wl { + // Rising half: ramps from -amplitude to +amplitude. + (2 * amplitude * angle_mod) / half_wl - amplitude + } else { + // Falling half: ramps from +amplitude to -amplitude. + amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl + }; + + // Perpendicular distance from voxel to channel centreline. + let perp_distance = (cross - meander_displacement).abs(); + + // Sub-chunk noise: ±1 m jitter on the channel edge (wavelength < 64 m). + // Use low bits of seed for integer jitter — D-010. + let edge_jitter = (sub_chunk_seed & 0x3) as i32; // [0, 3] + let half_width = (chunk.channel_width_m / 2).max(1); + let channel_edge = half_width + edge_jitter; + let deep_edge = (half_width / 2).max(1); + + let in_channel = perp_distance <= channel_edge; + let is_deep = perp_distance <= deep_edge; + + (in_channel, is_deep) +} + +/// Apply sub-chunk vegetation scatter using the sub-chunk seed. +/// +/// The base class from the region establishes the dominant cover type; the +/// scatter adds variety within the class (e.g. some Grass in a Forest zone +/// at clearings). Wavelength < 64 m — seeded from sub_chunk_seed (D-239 §10). +/// +/// All integer arithmetic (D-010). +fn scatter_vegetation(base: Vegetation, sub_chunk_seed: u64) -> Vegetation { + // Use bits [4:6] of the seed for scatter — independent from the channel + // computation which uses bits [0:3]. + let scatter_bits = ((sub_chunk_seed >> 4) & 0xF) as u8; + + match base { + Vegetation::Forest => { + // 15% Grass clearings, 15% Scrub margin, 70% Forest. + if scatter_bits < 3 { + Vegetation::Grass + } else if scatter_bits < 6 { + Vegetation::Scrub + } else { + Vegetation::Forest + } + } + Vegetation::Scrub => { + // 20% Grass, 80% Scrub. + if scatter_bits < 4 { + Vegetation::Grass + } else { + Vegetation::Scrub + } + } + Vegetation::Thicket => { + // Riparian thicket: mostly Thicket with some Forest. + if scatter_bits < 5 { + Vegetation::Thicket + } else { + Vegetation::Forest + } + } + // Barren, Grass, Crop, Cleared: no scatter. + other => other, + } +} + +/// Fold a `VoxelPos` into a u64 id for seed derivation. +/// +/// Same bijective approach as `chunk_context::pos_to_id` and +/// `domain_warp::pos_to_id` — zigzag encode + Cantor pairing. Integer-only (D-010). +#[inline] +fn voxel_pos_to_id(pos: VoxelPos) -> 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) +} + +// --------------------------------------------------------------------------- +// VoxelCache — LRU-style on-demand cache (D-227) +// --------------------------------------------------------------------------- + +/// Cache key: `(body_id, voxel_pos_after_warp)`. +/// +/// The body_id is stored as a u64 hash (FNV-1a) to keep the key `Copy` +/// and `Ord`-able (D-010: `BTreeMap` key). +type CacheKey = (u64, VoxelPos); + +/// One cache entry: the derived column plus an access generation counter. +struct CacheEntry { + column: VoxelColumn, + /// Monotonically increasing access counter — the LRU eviction key. + access_gen: u64, +} + +/// Bounded LRU-style cache for derived `VoxelColumn`s (D-227). +/// +/// ## Invariants +/// +/// - At most `capacity` entries are stored at any time. +/// - Entries are evicted in LRU order (lowest `access_gen`). +/// - Same `(seed, body_id, tile_pos)` → same column, always. +/// - Nothing is persisted to disk; eviction forces re-derivation. +/// +/// ## D-010 compliance +/// +/// Uses `BTreeMap` (ordered) for the cache store. Iteration order is +/// deterministic; eviction scans for the minimum `access_gen`. +pub struct VoxelCache { + capacity: usize, + entries: BTreeMap, + gen: u64, +} + +impl VoxelCache { + /// Create a new cache with the given capacity. + /// + /// The default capacity in production is [`DEFAULT_VOXEL_CACHE_CAPACITY`]. + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + entries: BTreeMap::new(), + gen: 0, + } + } + + /// Look up or derive the `VoxelColumn` for the given position. + /// + /// Cache hit: returns a clone of the stored column, bumps its access + /// generation (LRU recency). + /// Cache miss: derives the column, inserts it (evicting LRU if full), + /// returns a clone. + /// + /// The `body_id_hash` is the FNV-1a hash of the body identifier — + /// use [`crate::seed::fnv1a_64`] to compute it. + pub fn get_or_derive( + &mut self, + world_seed: u64, + body_id: &str, + body_id_hash: u64, + region: &RegionProfile, + chunk: &ChunkContext, + tile_x: i32, + tile_y: i32, + ) -> VoxelColumn { + // Apply domain warp to get the canonical voxel address (same as in + // derive_voxel_column — the cache key is the POST-warp voxel address). + let (dx, dy) = domain_warp(world_seed, body_id, (tile_x, tile_y)); + let voxel_x = (tile_x as f64 + dx) as i32; + let voxel_y = (tile_y as f64 + dy) as i32; + let key: CacheKey = (body_id_hash, (voxel_x, voxel_y)); + + self.gen += 1; + let current_gen = self.gen; + + if let Some(entry) = self.entries.get_mut(&key) { + entry.access_gen = current_gen; + return entry.column.clone(); + } + + // Cache miss — derive and insert. + let column = derive_voxel_column(world_seed, body_id, region, chunk, tile_x, tile_y); + + // Evict LRU if at capacity. + if self.entries.len() >= self.capacity { + self.evict_lru(); + } + + self.entries.insert( + key, + CacheEntry { + column: column.clone(), + access_gen: current_gen, + }, + ); + + column + } + + /// Number of entries currently in the cache. + #[cfg(test)] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the cache holds no entries. + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Evict the entry with the lowest `access_gen` (the LRU entry). + fn evict_lru(&mut self) { + if self.entries.is_empty() { + return; + } + // Scan for the minimum access_gen. BTreeMap iteration is ordered by + // key (deterministic, D-010), so a tie is broken by key order. + let lru_key = self + .entries + .iter() + .min_by_key(|(_, v)| v.access_gen) + .map(|(k, _)| *k) + .expect("entries is non-empty"); + self.entries.remove(&lru_key); + } +} + +/// Default voxel cache capacity for production use. +/// +/// 64×64 = 4096 voxels per chunk; with a 4-chunk radius lookahead (common +/// streaming pattern) that is ~65k voxels in flight. 8k entries keeps the +/// most-recently-used chunk and a partial second chunk warm without blowing +/// the memory budget (~200 bytes/entry × 8192 = ~1.6 MB). +pub const DEFAULT_VOXEL_CACHE_CAPACITY: usize = 8192; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::chunk_context::derive_chunk_context; + use crate::atlas::region_profile::{ + GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass, + }; + use crate::seed::fnv1a_64; + + // ----------------------------------------------------------------------- + // Test fixtures + // ----------------------------------------------------------------------- + + fn alluvial_region() -> RegionProfile { + RegionProfile { + 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, + } + } + + fn alluvial_chunk(region: &RegionProfile) -> ChunkContext { + derive_chunk_context(42, "GJ1c", region, (10, 20)) + } + + // ----------------------------------------------------------------------- + // TerrainMaterial discriminant pins + // ----------------------------------------------------------------------- + + #[test] + fn terrain_material_discriminants_pinned() { + // Append-only invariant (D-010). + assert_eq!(TerrainMaterial::Soil as u8, 0); + assert_eq!(TerrainMaterial::Sand as u8, 1); + assert_eq!(TerrainMaterial::Gravel as u8, 2); + assert_eq!(TerrainMaterial::Rock as u8, 3); + assert_eq!(TerrainMaterial::Wetland as u8, 4); + assert_eq!(TerrainMaterial::Lava as u8, 5); + } + + #[test] + fn water_discriminants_pinned() { + assert_eq!(Water::Dry as u8, 0); + assert_eq!(Water::Shallow as u8, 1); + assert_eq!(Water::Deep as u8, 2); + } + + // ----------------------------------------------------------------------- + // AlluvialPlain properties + // ----------------------------------------------------------------------- + + #[test] + fn alluvial_plain_produces_soil_terrain() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + // Sample a voxel that shouldn't be in a wetland zone. + let col = derive_voxel_column(42, "GJ1c", ®ion, &chunk, 100, 100); + assert_eq!( + col.terrain, + TerrainMaterial::Soil, + "AlluvialPlain must produce Soil terrain" + ); + } + + #[test] + fn alluvial_plain_floor_is_none() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let col = derive_voxel_column(42, "GJ1c", ®ion, &chunk, 100, 100); + assert_eq!(col.floor, FloorMaterial::None, "Phase 4 floor must be None"); + } + + #[test] + fn alluvial_plain_vegetation_from_region_class() { + let region = alluvial_region(); // vegetation_class = Forest + let chunk = alluvial_chunk(®ion); + // Multiple samples to account for sub-chunk scatter; all should be + // in the Forest/Scrub/Grass range (D-239 §8: no-skip law — no Barren in Forest zone). + for tx in [100, 101, 102, 103, 104] { + let col = derive_voxel_column(42, "GJ1c", ®ion, &chunk, tx, 100); + assert!( + matches!( + col.vegetation, + Vegetation::Forest | Vegetation::Scrub | Vegetation::Grass + ), + "Forest region must produce Forest/Scrub/Grass vegetation, got {:?}", + col.vegetation + ); + } + } + + #[test] + fn elevation_is_non_negative() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + for (tx, ty) in [(100, 100), (0, 0), (-50, 25), (200, -10)] { + let col = derive_voxel_column(42, "GJ1c", ®ion, &chunk, tx, ty); + assert!( + col.elevation_m >= 0, + "elevation must be >= 0, got {}", + col.elevation_m + ); + } + } + + // ----------------------------------------------------------------------- + // Determinism — the core T-1028 contract + // ----------------------------------------------------------------------- + + /// End-to-end determinism: same (seed, body, region) → same voxel columns. + /// Two calls at the same position must produce identical results. + #[test] + fn voxel_derivation_is_deterministic() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + + let positions = [(100, 100), (0, 0), (-50, 25), (200, -10), (64, 64)]; + for (tx, ty) in positions { + let a = derive_voxel_column(42, "GJ1c", ®ion, &chunk, tx, ty); + let b = derive_voxel_column(42, "GJ1c", ®ion, &chunk, tx, ty); + assert_eq!( + a, b, + "voxel at ({tx},{ty}) must be identical on repeated derivation" + ); + } + } + + /// Different positions must (in general) produce different columns. + #[test] + fn different_positions_produce_different_outputs() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let a = derive_voxel_column(42, "GJ1c", ®ion, &chunk, 100, 100); + let b = derive_voxel_column(42, "GJ1c", ®ion, &chunk, 1000, 2000); + // Not strictly guaranteed (hash collision possible) but overwhelmingly likely. + assert!( + a != b || a.elevation_m != b.elevation_m, + "positions 100m apart should produce different columns" + ); + } + + /// Different world seeds produce different results. + #[test] + fn different_seeds_produce_different_outputs() { + let region = alluvial_region(); + let chunk1 = derive_chunk_context(1, "GJ1c", ®ion, (10, 20)); + let chunk2 = derive_chunk_context(2, "GJ1c", ®ion, (10, 20)); + let a = derive_voxel_column(1, "GJ1c", ®ion, &chunk1, 100, 100); + let b = derive_voxel_column(2, "GJ1c", ®ion, &chunk2, 100, 100); + assert!( + a != b, + "different world seeds must produce different voxel outputs" + ); + } + + // ----------------------------------------------------------------------- + // Cache behaviour (D-227) + // ----------------------------------------------------------------------- + + #[test] + fn cache_returns_same_column_as_direct_derivation() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let body_id = "GJ1c"; + let body_id_hash = fnv1a_64(body_id); + + let mut cache = VoxelCache::new(64); + let cached = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 100, 100); + let direct = derive_voxel_column(42, body_id, ®ion, &chunk, 100, 100); + assert_eq!( + cached, direct, + "cache must return same result as direct derivation" + ); + } + + #[test] + fn cache_second_call_is_a_hit() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let body_id = "GJ1c"; + let body_id_hash = fnv1a_64(body_id); + + let mut cache = VoxelCache::new(64); + let first = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 100, 100); + assert_eq!(cache.len(), 1, "first call must insert one entry"); + let second = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 100, 100); + assert_eq!( + cache.len(), + 1, + "second call must be a cache hit, not a new insertion" + ); + assert_eq!(first, second, "cache hit must return same column"); + } + + #[test] + fn cache_evicts_lru_on_overflow() { + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let body_id = "GJ1c"; + let body_id_hash = fnv1a_64(body_id); + + let capacity = 8; + let mut cache = VoxelCache::new(capacity); + + // Fill cache to capacity with distinct positions. + for i in 0..capacity as i32 { + cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, i * 64, 0); + } + assert_eq!(cache.len(), capacity); + + // Access position 0 to refresh it (not the LRU). + cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 0, 0); + + // Insert a new entry — should evict the LRU (position 64, not 0). + cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 999, 999); + assert_eq!( + cache.len(), + capacity, + "cache size must stay at capacity after eviction" + ); + } + + #[test] + fn cache_eviction_does_not_break_derivation() { + // After eviction, re-deriving the evicted entry must produce the same result. + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + let body_id = "GJ1c"; + let body_id_hash = fnv1a_64(body_id); + + let capacity = 2; + let mut cache = VoxelCache::new(capacity); + + // Populate the cache fully. + let col0 = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 0, 0); + let _col1 = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 100, 0); + + // Insert a third entry — evicts (0,0) which is the LRU. + let _col2 = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 200, 0); + + // Re-derive position (0,0) — cache miss, must re-derive correctly. + let col0_again = cache.get_or_derive(42, body_id, body_id_hash, ®ion, &chunk, 0, 0); + assert_eq!( + col0, col0_again, + "re-derived column after cache eviction must be identical" + ); + } + + // ----------------------------------------------------------------------- + // Domain warp sanity + // ----------------------------------------------------------------------- + + #[test] + fn domain_warp_is_applied_before_derivation() { + // The warp should produce different columns at nearby integer tile + // coordinates — the warp displaces the lookup point, so adjacent tiles + // should not always look identical even when terrain is homogeneous. + let region = alluvial_region(); + let chunk = alluvial_chunk(®ion); + + // Sample a grid of 4 neighbouring tiles; at least some should differ. + let cols: Vec = (0..4) + .map(|i| derive_voxel_column(42, "GJ1c", ®ion, &chunk, i, 0)) + .collect(); + let all_same = cols.windows(2).all(|w| w[0] == w[1]); + assert!( + !all_same, + "adjacent tiles should differ after domain warp (elevation scatter)" + ); + } + + // ----------------------------------------------------------------------- + // Family dispatch stubs — must not panic + // ----------------------------------------------------------------------- + + #[test] + fn lava_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::Volcanic, + tectonic_class: TectonicClass::Volcanic, + vegetation_class: VegetationClass::Barren, + ocean_fraction_q: 0, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "Io", ®ion, (0, 0)); + let col = derive_voxel_column(42, "Io", ®ion, &chunk, 10, 10); + assert_eq!( + col.terrain, + TerrainMaterial::Lava, + "LavaField stub must set Lava terrain" + ); + assert_eq!(col.floor, FloorMaterial::None); + } + + #[test] + fn fjord_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::Fjord, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "fjord_body", ®ion, (0, 0)); + let _col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, 10, 10); + // No panic = pass. + } + + #[test] + fn cliff_coast_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::CliffCoast, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "cliff_body", ®ion, (0, 0)); + let col = derive_voxel_column(42, "cliff_body", ®ion, &chunk, 10, 10); + assert_eq!(col.terrain, TerrainMaterial::Rock); + } + + #[test] + fn braided_delta_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::Delta, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0)); + let col = derive_voxel_column(42, "delta_body", ®ion, &chunk, 10, 10); + assert_eq!(col.terrain, TerrainMaterial::Gravel); + } + + #[test] + fn dune_strand_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::DuneStrand, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "dune_body", ®ion, (0, 0)); + let col = derive_voxel_column(42, "dune_body", ®ion, &chunk, 10, 10); + assert_eq!(col.terrain, TerrainMaterial::Sand); + } + + #[test] + fn incised_gorge_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::MountainPass, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0)); + let col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 10, 10); + assert_eq!(col.terrain, TerrainMaterial::Rock); + } + + #[test] + fn meander_reach_family_stub_does_not_panic() { + let region = RegionProfile { + morphology_zone: MorphologyZone::MeanderReach, + ..alluvial_region() + }; + let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0)); + let _col = derive_voxel_column(42, "meander_body", ®ion, &chunk, 10, 10); + // No panic = pass. + } +} diff --git a/server/src/seed.rs b/server/src/seed.rs index abbb7407d..89f42954b 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -97,6 +97,9 @@ pub enum SeedDomain { /// Anti-squaring domain warp (D-239 §4, T-1026). /// Keyed by per-tile position id (see `atlas::domain_warp::pos_to_id`). DomainWarp = 7, + /// ChunkContext derivation (64 m carrier, D-239 §1, T-1028). + /// Keyed by region-scale position id (see `atlas::chunk_context::pos_to_id`). + ChunkContext = 8, } /// A position in the deterministic seed tree (D-224). @@ -253,6 +256,7 @@ mod tests { assert_eq!(SeedDomain::Block as u64, 5); assert_eq!(SeedDomain::Npc as u64, 6); assert_eq!(SeedDomain::DomainWarp as u64, 7); + assert_eq!(SeedDomain::ChunkContext as u64, 8); } #[test]