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:
+136
-18
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user