Files
settled-reach/server/src/atlas/mosaic.rs
T
jpmschweitzerandClaude Opus 4.8 5fb34675eb feat(simulation): grassland biome-awareness + alien-ecology vocab (T-1085 workstream 4, D-246)
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>
2026-07-04 20:30:33 +02:00

414 lines
16 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.
//! 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)
);
}
}