The mosaic palette gains a climate-biome dimension for the Grassland surface-class: savanna / temperate / boreal / tundra now read distinctly rather than collapsing to one biome-blind palette (the D-246 biome-awareness fix). - atlas/mosaic.rs: ClimateBiome (from temperature_c + moisture_q) + four biome-keyed Grassland palettes; palette(class, biome). Savanna carries scattered Forest copses, tundra carries lichen + sparse bare ground and no forest. §8-safe (veg + water only). - atlas/voxel.rs: derive the biome; a cold barren district (tundra/boreal) now takes the sparse mosaic rather than staying blank — worlds that read "0 vegetated / blank" now read structured-sparse (D-245 climate-appropriateness). vegetated_districts 8→20 and 0→39 on the cold validation bodies. - data/mosaic_constants.toml: biome-keyed grassland mirror. - docs/design/alien-ecology-vocabulary.md: Miri's full biome × register naming layer (the cane/vane/wrack/husk/drum-root native lexicon + Earth-register names) — the derived-label source for the palette and Phase-5 rendering. Believability golden regenerated. Full server suite green. Forest biomes and the introduced-Earth register are the noted continuation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
16 KiB
Rust
414 lines
16 KiB
Rust
//! Intra-class micro-habitat mosaic (D-246, T-1084) — the sub-chunk palette that
|
||
//! modulates the D-228 axes into a spatially-coherent, relief/moisture-conditioned
|
||
//! mosaic of micro-habitats within a single terrain class.
|
||
//!
|
||
//! Mechanism (D-246): [`detail_scatter::voxel_mosaic`](crate::atlas::detail_scatter::voxel_mosaic)
|
||
//! supplies a smooth, body-global selection field in `[0, 1]`; this module maps a
|
||
//! voxel's base surface-class to a weighted palette of micro-habitats and picks one
|
||
//! by thresholding that field against cumulative weights — conditioned on a local
|
||
//! wetness signal (relief micro-thalweg + district moisture) so wet habitats
|
||
//! (creek / bog / marsh) land in the lows and dry ones (meadow / glade / clearing) on
|
||
//! the rises. Selection is **integer basis-points** (D-010): the f64 field and
|
||
//! wetness are the sanctioned positional inputs, truncated before the integer decision.
|
||
//!
|
||
//! Micro-habitats are distinct D-228 **axis-triples**, not names — several display
|
||
//! names (scree/talus, outcrop/ledge) collapse to one triple, the name being a
|
||
//! downstream derived label (D-228).
|
||
//!
|
||
//! Data residency (D-246): the palette is source-canonical in
|
||
//! `server/data/mosaic_constants.toml`; the embedded consts here mirror it and are
|
||
//! the authoritative runtime source today — the same embedded-default + TOML-mirror
|
||
//! pattern as `climate_constants.toml` (runtime loading lands with production tuning).
|
||
//! This is the **temperate / native baseline** register; biome variants
|
||
//! (savanna / tundra / boreal) and the introduced-Earth register extend the table via
|
||
//! T-1085 before generation.
|
||
|
||
use crate::atlas::voxel::{TerrainMaterial, Vegetation, Water};
|
||
|
||
/// How the local wetness signal reweights a micro-habitat.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MoistureAffinity {
|
||
/// Unconditioned — flat weight across wet and dry.
|
||
Any,
|
||
/// Favoured in the wet lows (creek / bog / marsh / oasis / active channel).
|
||
Wet,
|
||
/// Favoured on the dry rises (meadow / glade / clearing / dune crest / hardpan).
|
||
Dry,
|
||
}
|
||
|
||
/// One micro-habitat: a distinct D-228 axis-triple the mosaic can paint, plus its
|
||
/// relative weight and moisture affinity. `name` is documentation / a derived-label
|
||
/// seed only — it is never stored on the voxel.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct MicroHabitat {
|
||
pub name: &'static str,
|
||
pub terrain: TerrainMaterial,
|
||
pub vegetation: Vegetation,
|
||
pub water: Water,
|
||
pub weight: i32,
|
||
pub affinity: MoistureAffinity,
|
||
}
|
||
|
||
/// The surface-class palette key (D-246) — derived from the base voxel's terrain +
|
||
/// dominant vegetation, never stored.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SurfaceClass {
|
||
Wetland,
|
||
Forest,
|
||
Grassland,
|
||
Sand,
|
||
Rock,
|
||
Gravel,
|
||
Lava,
|
||
}
|
||
|
||
impl SurfaceClass {
|
||
/// Palette key from the family generator's base output (D-246 Palette key):
|
||
/// terrain material selects directly, except `Soil` splits Forest vs Grassland
|
||
/// by the district's dominant vegetation. `Hardpan`/`Scree` never arrive as a
|
||
/// *base* material (they are mosaic outputs) but map defensively.
|
||
pub fn from_base(terrain: TerrainMaterial, dominant_vegetation: Vegetation) -> Self {
|
||
match terrain {
|
||
TerrainMaterial::Wetland => SurfaceClass::Wetland,
|
||
TerrainMaterial::Sand | TerrainMaterial::Hardpan => SurfaceClass::Sand,
|
||
TerrainMaterial::Rock | TerrainMaterial::Scree => SurfaceClass::Rock,
|
||
TerrainMaterial::Gravel => SurfaceClass::Gravel,
|
||
TerrainMaterial::Lava => SurfaceClass::Lava,
|
||
TerrainMaterial::Soil => {
|
||
if matches!(dominant_vegetation, Vegetation::Forest) {
|
||
SurfaceClass::Forest
|
||
} else {
|
||
SurfaceClass::Grassland
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The district's climate-biome (T-1085, D-246) — derived from `temperature_c` +
|
||
/// `moisture_q`. It refines the *vegetated* surface-class palettes so a Grassland reads
|
||
/// as savanna / temperate / boreal / tundra rather than one biome-blind palette (the
|
||
/// D-246 biome-awareness fix). The alien display names per biome × register live in
|
||
/// `docs/design/alien-ecology-vocabulary.md`; this layer carries only the D-228 axes.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ClimateBiome {
|
||
Tropical,
|
||
Temperate,
|
||
Boreal,
|
||
Tundra,
|
||
Savanna,
|
||
}
|
||
|
||
impl ClimateBiome {
|
||
/// Coarse biome from mean-annual temperature (°C) + district moisture (0–100).
|
||
/// Airless districts never reach the mosaic; the `10.0` default is a safe
|
||
/// temperate fallback for a missing temperature.
|
||
pub fn from_climate(temperature_c: Option<f32>, moisture_q: i32) -> Self {
|
||
let t = temperature_c.unwrap_or(10.0);
|
||
if t < 0.0 {
|
||
ClimateBiome::Tundra
|
||
} else if t < 7.0 {
|
||
ClimateBiome::Boreal
|
||
} else if t <= 20.0 {
|
||
ClimateBiome::Temperate
|
||
} else if moisture_q >= 45 {
|
||
ClimateBiome::Tropical
|
||
} else {
|
||
ClimateBiome::Savanna
|
||
}
|
||
}
|
||
}
|
||
|
||
// Shorthand for the const tables below.
|
||
use MoistureAffinity::{Any, Dry, Wet};
|
||
use TerrainMaterial as T;
|
||
use Vegetation as V;
|
||
use Water as W;
|
||
|
||
const fn h(
|
||
name: &'static str,
|
||
terrain: TerrainMaterial,
|
||
vegetation: Vegetation,
|
||
water: Water,
|
||
weight: i32,
|
||
affinity: MoistureAffinity,
|
||
) -> MicroHabitat {
|
||
MicroHabitat {
|
||
name,
|
||
terrain,
|
||
vegetation,
|
||
water,
|
||
weight,
|
||
affinity,
|
||
}
|
||
}
|
||
|
||
// ── Temperate / native baseline palettes (mirror of mosaic_constants.toml) ──────
|
||
|
||
const WETLAND: &[MicroHabitat] = &[
|
||
h("clearing", T::Wetland, V::Barren, W::Dry, 15, Dry),
|
||
h("copse", T::Wetland, V::Forest, W::Dry, 10, Dry),
|
||
h("creek", T::Wetland, V::Barren, W::Shallow, 10, Wet),
|
||
h("meadow", T::Wetland, V::Meadow, W::Dry, 15, Dry),
|
||
h("bog", T::Wetland, V::Scrub, W::Shallow, 12, Wet),
|
||
h("marsh", T::Wetland, V::Grass, W::Shallow, 12, Wet),
|
||
];
|
||
|
||
const FOREST: &[MicroHabitat] = &[
|
||
h("dense stand", T::Soil, V::Forest, W::Dry, 40, Any),
|
||
h("glade", T::Soil, V::Grass, W::Dry, 15, Dry),
|
||
h("deadfall", T::Soil, V::Deadfall, W::Dry, 10, Any),
|
||
h("brook", T::Soil, V::Thicket, W::Shallow, 10, Wet),
|
||
h("fern undergrowth", T::Soil, V::Thicket, W::Dry, 15, Any),
|
||
];
|
||
|
||
// Grassland is biome-keyed (T-1085): savanna / temperate / boreal / tundra read
|
||
// distinctly rather than as one biome-blind palette (D-246). §8-safe — vegetation +
|
||
// water only; terrain stays Soil in v1 (the Gravel dry-wash material variant is the
|
||
// §8-aware follow-up). Names per docs/design/alien-ecology-vocabulary.md.
|
||
const GRASSLAND_TEMPERATE: &[MicroHabitat] = &[
|
||
h("cane-tassel sweep", T::Soil, V::Grass, W::Dry, 40, Any),
|
||
h("drum-root knot", T::Soil, V::Scrub, W::Dry, 18, Dry),
|
||
h("wrack-bloom", T::Soil, V::Meadow, W::Dry, 17, Dry),
|
||
h("husk-line seep", T::Soil, V::Barren, W::Dry, 10, Wet),
|
||
];
|
||
|
||
// Savanna: the signature is scattered Forest copses in a grass matrix (the
|
||
// vane-crown copse) — the clearest biome distinction from temperate grassland.
|
||
const GRASSLAND_SAVANNA: &[MicroHabitat] = &[
|
||
h("cane-tassel sweep", T::Soil, V::Grass, W::Dry, 38, Any),
|
||
h("vane-crown copse", T::Soil, V::Forest, W::Dry, 12, Dry),
|
||
h("husk-thorn scrub", T::Soil, V::Scrub, W::Dry, 18, Dry),
|
||
h("husk-pan waterhole", T::Soil, V::Grass, W::Shallow, 10, Wet),
|
||
h("drum-mound flat", T::Soil, V::Barren, W::Dry, 12, Dry),
|
||
];
|
||
|
||
// Boreal grassland: cooler and sparser — more scrub, a lichen fraction, no meadow.
|
||
const GRASSLAND_BOREAL: &[MicroHabitat] = &[
|
||
h("cane-tassel sweep", T::Soil, V::Grass, W::Dry, 30, Any),
|
||
h("wrack-scrub", T::Soil, V::Scrub, W::Dry, 25, Dry),
|
||
h("husk-line seep", T::Soil, V::Grass, W::Shallow, 10, Wet),
|
||
h("wrack-lichen mat", T::Soil, V::Lichen, W::Dry, 12, Any),
|
||
h("cold scald", T::Soil, V::Barren, W::Dry, 15, Dry),
|
||
];
|
||
|
||
// Tundra: sparse and cold — scrub, lichen, bare patterned ground, NO forest.
|
||
const GRASSLAND_TUNDRA: &[MicroHabitat] = &[
|
||
h("husk-heath brake", T::Soil, V::Scrub, W::Dry, 26, Any),
|
||
h("wrack-lichen mat", T::Soil, V::Lichen, W::Dry, 24, Any),
|
||
h(
|
||
"thermokarst husk-pool",
|
||
T::Soil,
|
||
V::Grass,
|
||
W::Shallow,
|
||
10,
|
||
Wet,
|
||
),
|
||
h("lichen-scald flat", T::Soil, V::Barren, W::Dry, 30, Dry),
|
||
];
|
||
|
||
const SAND: &[MicroHabitat] = &[
|
||
h("dune crest", T::Sand, V::Barren, W::Dry, 25, Dry),
|
||
h("slack", T::Sand, V::Grass, W::Dry, 15, Wet),
|
||
h("hardpan", T::Hardpan, V::Barren, W::Dry, 20, Dry),
|
||
h("oasis", T::Sand, V::Forest, W::Shallow, 5, Wet),
|
||
h("scrub clump", T::Sand, V::Scrub, W::Dry, 15, Any),
|
||
];
|
||
|
||
const ROCK: &[MicroHabitat] = &[
|
||
h("outcrop", T::Rock, V::Barren, W::Dry, 45, Any),
|
||
h("scree", T::Scree, V::Barren, W::Dry, 30, Any),
|
||
h("lichen", T::Rock, V::Lichen, W::Dry, 25, Any),
|
||
];
|
||
|
||
const GRAVEL: &[MicroHabitat] = &[
|
||
h("bar", T::Gravel, V::Barren, W::Dry, 45, Any),
|
||
h("active channel", T::Gravel, V::Barren, W::Shallow, 25, Wet),
|
||
h("vegetated island", T::Gravel, V::Scrub, W::Dry, 30, Dry),
|
||
];
|
||
|
||
const LAVA: &[MicroHabitat] = &[
|
||
h("fresh sheet", T::Lava, V::Barren, W::Dry, 50, Any),
|
||
h("weathered crust", T::Lava, V::Lichen, W::Dry, 30, Any),
|
||
h("pioneer scrub", T::Lava, V::Scrub, W::Dry, 20, Dry),
|
||
];
|
||
|
||
/// The embedded native palette for a `(surface-class, climate-biome)` (D-246 Palette
|
||
/// key). Grassland is biome-keyed (T-1085); the other classes are biome-invariant in
|
||
/// v1 — Forest biomes (tropical/boreal) and the extreme classes are the follow-up.
|
||
pub fn palette(class: SurfaceClass, biome: ClimateBiome) -> &'static [MicroHabitat] {
|
||
match class {
|
||
SurfaceClass::Wetland => WETLAND,
|
||
SurfaceClass::Forest => FOREST,
|
||
SurfaceClass::Grassland => match biome {
|
||
ClimateBiome::Savanna | ClimateBiome::Tropical => GRASSLAND_SAVANNA,
|
||
ClimateBiome::Boreal => GRASSLAND_BOREAL,
|
||
ClimateBiome::Tundra => GRASSLAND_TUNDRA,
|
||
ClimateBiome::Temperate => GRASSLAND_TEMPERATE,
|
||
},
|
||
SurfaceClass::Sand => SAND,
|
||
SurfaceClass::Rock => ROCK,
|
||
SurfaceClass::Gravel => GRAVEL,
|
||
SurfaceClass::Lava => LAVA,
|
||
}
|
||
}
|
||
|
||
/// Moisture-conditioned effective weight of one entry, in the same integer units as
|
||
/// `weight`. `wet_bp` is the local wetness in basis-points (0..10000). Wet/Dry
|
||
/// affinities scale within `[0.2×, 1.0×]` (a 0.2 floor so a habitat can still appear
|
||
/// off-affinity); `Any` is flat.
|
||
fn effective_weight(hab: &MicroHabitat, wet_bp: i32) -> i64 {
|
||
let factor_bp: i32 = match hab.affinity {
|
||
MoistureAffinity::Any => 10_000,
|
||
MoistureAffinity::Wet => 2_000 + 8 * wet_bp / 10, // 2000..10000
|
||
MoistureAffinity::Dry => 2_000 + 8 * (10_000 - wet_bp) / 10,
|
||
};
|
||
(hab.weight as i64 * factor_bp as i64) / 10_000
|
||
}
|
||
|
||
/// Pick a micro-habitat from `palette` using the mosaic selection field and a local
|
||
/// wetness signal. Integer basis-points (D-010): `field` and `wetness` are the
|
||
/// sanctioned f64 positional inputs, truncated to bp before the integer decision.
|
||
///
|
||
/// - `field`: the [`voxel_mosaic`](crate::atlas::detail_scatter::voxel_mosaic) scalar in `[0, 1]`.
|
||
/// - `wetness`: local wet/dry signal in `[0, 1]` (relief micro-thalweg + moisture).
|
||
pub fn select(palette: &[MicroHabitat], field: f64, wetness: f64) -> &MicroHabitat {
|
||
debug_assert!(!palette.is_empty(), "mosaic palette must be non-empty");
|
||
let wet_bp = (wetness.clamp(0.0, 1.0) * 10_000.0) as i32; // truncate (D-010)
|
||
let total: i64 = palette
|
||
.iter()
|
||
.map(|hab| effective_weight(hab, wet_bp))
|
||
.sum::<i64>()
|
||
.max(1);
|
||
let field_bp = (field.clamp(0.0, 1.0) * 10_000.0) as i64; // truncate (D-010)
|
||
let mut threshold = field_bp * total / 10_000;
|
||
for hab in palette {
|
||
let w = effective_weight(hab, wet_bp);
|
||
if threshold < w {
|
||
return hab;
|
||
}
|
||
threshold -= w;
|
||
}
|
||
// field == 1.0 edge → last bucket.
|
||
palette.last().expect("palette non-empty")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn from_base_maps_soil_by_vegetation() {
|
||
assert_eq!(
|
||
SurfaceClass::from_base(T::Soil, V::Forest),
|
||
SurfaceClass::Forest
|
||
);
|
||
assert_eq!(
|
||
SurfaceClass::from_base(T::Soil, V::Grass),
|
||
SurfaceClass::Grassland
|
||
);
|
||
assert_eq!(
|
||
SurfaceClass::from_base(T::Wetland, V::Barren),
|
||
SurfaceClass::Wetland
|
||
);
|
||
assert_eq!(
|
||
SurfaceClass::from_base(T::Lava, V::Barren),
|
||
SurfaceClass::Lava
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_palette_nonempty_and_positively_weighted() {
|
||
let classes = [
|
||
SurfaceClass::Wetland,
|
||
SurfaceClass::Forest,
|
||
SurfaceClass::Grassland,
|
||
SurfaceClass::Sand,
|
||
SurfaceClass::Rock,
|
||
SurfaceClass::Gravel,
|
||
SurfaceClass::Lava,
|
||
];
|
||
let biomes = [
|
||
ClimateBiome::Tropical,
|
||
ClimateBiome::Temperate,
|
||
ClimateBiome::Boreal,
|
||
ClimateBiome::Tundra,
|
||
ClimateBiome::Savanna,
|
||
];
|
||
for class in classes {
|
||
for biome in biomes {
|
||
let p = palette(class, biome);
|
||
assert!(!p.is_empty(), "{class:?}/{biome:?} palette empty");
|
||
assert!(
|
||
p.iter().all(|hab| hab.weight > 0),
|
||
"{class:?}/{biome:?} has a non-positive weight"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn from_climate_bands() {
|
||
use ClimateBiome::*;
|
||
assert_eq!(ClimateBiome::from_climate(Some(-10.0), 40), Tundra);
|
||
assert_eq!(ClimateBiome::from_climate(Some(3.0), 40), Boreal);
|
||
assert_eq!(ClimateBiome::from_climate(Some(12.0), 40), Temperate);
|
||
assert_eq!(ClimateBiome::from_climate(Some(28.0), 70), Tropical);
|
||
assert_eq!(ClimateBiome::from_climate(Some(28.0), 20), Savanna);
|
||
}
|
||
|
||
#[test]
|
||
fn grassland_biomes_are_distinct() {
|
||
// Savanna carries scattered Forest copses; tundra carries none but has Lichen.
|
||
let savanna = palette(SurfaceClass::Grassland, ClimateBiome::Savanna);
|
||
let tundra = palette(SurfaceClass::Grassland, ClimateBiome::Tundra);
|
||
assert!(
|
||
savanna.iter().any(|h| h.vegetation == V::Forest),
|
||
"savanna should have Forest copses"
|
||
);
|
||
assert!(
|
||
!tundra.iter().any(|h| h.vegetation == V::Forest),
|
||
"tundra must not carry Forest"
|
||
);
|
||
assert!(
|
||
tundra.iter().any(|h| h.vegetation == V::Lichen),
|
||
"tundra should have Lichen"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn select_is_deterministic_and_spans_the_palette() {
|
||
let p = palette(SurfaceClass::Wetland, ClimateBiome::Temperate);
|
||
assert_eq!(select(p, 0.37, 0.5).name, select(p, 0.37, 0.5).name);
|
||
let names: std::collections::BTreeSet<_> = (0..100)
|
||
.map(|i| select(p, i as f64 / 100.0, 0.5).name)
|
||
.collect();
|
||
assert!(
|
||
names.len() >= 3,
|
||
"selection collapsed to {} habitats",
|
||
names.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wetness_favours_wet_habitats_over_dry_signal() {
|
||
let p = palette(SurfaceClass::Wetland, ClimateBiome::Temperate);
|
||
let wet_count = |wetness: f64| {
|
||
(0..100)
|
||
.filter(|i| {
|
||
matches!(
|
||
select(p, *i as f64 / 100.0, wetness).affinity,
|
||
MoistureAffinity::Wet
|
||
)
|
||
})
|
||
.count()
|
||
};
|
||
assert!(
|
||
wet_count(0.95) > wet_count(0.05),
|
||
"wet conditioning did not favour wet habitats: {} vs {}",
|
||
wet_count(0.95),
|
||
wet_count(0.05)
|
||
);
|
||
}
|
||
}
|