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>
This commit is contained in:
2026-07-04 20:30:33 +02:00
co-authored by Claude Opus 4.8
parent 388168eef5
commit 5fb34675eb
7 changed files with 616 additions and 33 deletions
+136 -18
View File
@@ -85,6 +85,40 @@ impl SurfaceClass {
}
}
/// 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;
@@ -128,11 +162,49 @@ const FOREST: &[MicroHabitat] = &[
h("fern undergrowth", T::Soil, V::Thicket, W::Dry, 15, Any),
];
const GRASSLAND: &[MicroHabitat] = &[
h("tussock", T::Soil, V::Grass, W::Dry, 40, Any),
h("scrub island", T::Soil, V::Scrub, W::Dry, 18, Dry),
h("wildflower", T::Soil, V::Meadow, W::Dry, 17, Dry),
h("dry wash", T::Gravel, V::Barren, W::Dry, 10, Wet),
// 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] = &[
@@ -161,12 +233,19 @@ const LAVA: &[MicroHabitat] = &[
h("pioneer scrub", T::Lava, V::Scrub, W::Dry, 20, Dry),
];
/// The embedded palette for a surface-class (temperate / native baseline).
pub fn palette(class: SurfaceClass) -> &'static [MicroHabitat] {
/// 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 => GRASSLAND,
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,
@@ -240,7 +319,7 @@ mod tests {
#[test]
fn every_palette_nonempty_and_positively_weighted() {
for class in [
let classes = [
SurfaceClass::Wetland,
SurfaceClass::Forest,
SurfaceClass::Grassland,
@@ -248,19 +327,58 @@ mod tests {
SurfaceClass::Rock,
SurfaceClass::Gravel,
SurfaceClass::Lava,
] {
let p = palette(class);
assert!(!p.is_empty(), "{class:?} palette empty");
assert!(
p.iter().all(|hab| hab.weight > 0),
"{class:?} has a non-positive weight"
);
];
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);
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)
@@ -274,7 +392,7 @@ mod tests {
#[test]
fn wetness_favours_wet_habitats_over_dry_signal() {
let p = palette(SurfaceClass::Wetland);
let p = palette(SurfaceClass::Wetland, ClimateBiome::Temperate);
let wet_count = |wetness: f64| {
(0..100)
.filter(|i| {
+15 -3
View File
@@ -530,10 +530,22 @@ pub fn derive_voxel_column(
use crate::atlas::mosaic::{self, SurfaceClass};
let dominant_veg = Vegetation::from_vegetation_class(district_eff.vegetation_class);
let class = SurfaceClass::from_base(column.terrain, dominant_veg);
// Climate-biome (T-1085) refines the Grassland palette: savanna / temperate /
// boreal / tundra read distinctly (D-246 biome-awareness).
let biome =
mosaic::ClimateBiome::from_climate(district_eff.temperature_c, district_eff.moisture_q);
let apply = match class {
SurfaceClass::Wetland | SurfaceClass::Forest => true,
// Barren grassland (cold / arid) stays barren — no imposed grass.
SurfaceClass::Grassland => !matches!(dominant_veg, Vegetation::Barren),
// A vegetated grassland always; a barren-dominant one only when the biome is
// cold (tundra / boreal), where the sparse lichen/scrub mosaic is climate-
// appropriate — a hot barren district (desert on Soil) stays bare.
SurfaceClass::Grassland => {
!matches!(dominant_veg, Vegetation::Barren)
|| matches!(
biome,
mosaic::ClimateBiome::Tundra | mosaic::ClimateBiome::Boreal
)
}
// Rock / Sand / Gravel / Lava are material-driven — a §8-aware follow-up.
_ => false,
};
@@ -550,7 +562,7 @@ pub fn derive_voxel_column(
// lowered on the rises (relief_signal negative in valleys).
let moisture = (district_eff.moisture_q as f64 / 100.0).clamp(0.0, 1.0);
let wetness = (moisture - relief_signal).clamp(0.0, 1.0);
let hab = mosaic::select(mosaic::palette(class), field, wetness);
let hab = mosaic::select(mosaic::palette(class, biome), field, wetness);
column.vegetation = hab.vegetation;
// Add standing water only where the family left it dry AND the chunk's
// hydrology gate allows it (T-1040: water placement must agree with