feat(@projects/@magic-civilization): add biome climate substrate rules

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 16:11:24 -07:00
parent 2fe49402de
commit c1358c9d2d
2 changed files with 314 additions and 13 deletions

View file

@ -0,0 +1,143 @@
{
"$schema_note": "Maps a procedural biome_id to the set of (substrate, t_band range, p_band range) cells in which authored flora species written for that biome may appear. Inverts mc-climate::derive::classify_terrain_whittaker and extends it to biome aliases the classifier does not directly emit (e.g. 'savanna', 'temperate_forest'). Consumed by mc-flora::generation::load_authored_species_for_biome to predicate-match species substrate_climate[] entries. JSON tunable per Rail-2.",
"biomes": {
"temperate_forest": [
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "bedrock", "t_band_min": 2, "t_band_max": 3, "p_band_min": 2, "p_band_max": 4 }
],
"forest": [
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "bedrock", "t_band_min": 2, "t_band_max": 3, "p_band_min": 2, "p_band_max": 4 }
],
"tropical_rainforest": [
{ "substrate": "soil", "t_band_min": 4, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"jungle": [
{ "substrate": "soil", "t_band_min": 4, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"boreal_forest": [
{ "substrate": "soil", "t_band_min": 1, "t_band_max": 1, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "bedrock", "t_band_min": 1, "t_band_max": 1, "p_band_min": 2, "p_band_max": 4 }
],
"taiga": [
{ "substrate": "soil", "t_band_min": 1, "t_band_max": 1, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "bedrock", "t_band_min": 1, "t_band_max": 1, "p_band_min": 2, "p_band_max": 4 }
],
"grassland": [
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 1, "p_band_max": 1 },
{ "substrate": "soil", "t_band_min": 4, "t_band_max": 4, "p_band_min": 2, "p_band_max": 2 }
],
"meadow": [
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 1, "p_band_max": 2 }
],
"plains": [
{ "substrate": "soil", "t_band_min": 3, "t_band_max": 3, "p_band_min": 2, "p_band_max": 2 },
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 1, "p_band_max": 2 }
],
"savanna": [
{ "substrate": "soil", "t_band_min": 3, "t_band_max": 4, "p_band_min": 1, "p_band_max": 2 },
{ "substrate": "sand", "t_band_min": 3, "t_band_max": 4, "p_band_min": 0, "p_band_max": 1 }
],
"tropical_savanna": [
{ "substrate": "soil", "t_band_min": 3, "t_band_max": 4, "p_band_min": 1, "p_band_max": 2 }
],
"desert": [
{ "substrate": "sand", "t_band_min": 2, "t_band_max": 4, "p_band_min": 0, "p_band_max": 1 },
{ "substrate": "bedrock", "t_band_min": 2, "t_band_max": 4, "p_band_min": 0, "p_band_max": 0 }
],
"hot_desert": [
{ "substrate": "sand", "t_band_min": 3, "t_band_max": 4, "p_band_min": 0, "p_band_max": 1 }
],
"cold_desert": [
{ "substrate": "sand", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 1 },
{ "substrate": "bedrock", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 0 }
],
"tundra": [
{ "substrate": "permafrost", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 2 },
{ "substrate": "bedrock", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 2 }
],
"arctic_tundra": [
{ "substrate": "permafrost", "t_band_min": 0, "t_band_max": 0, "p_band_min": 0, "p_band_max": 2 }
],
"alpine_tundra": [
{ "substrate": "permafrost", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 2 },
{ "substrate": "bedrock", "t_band_min": 1, "t_band_max": 2, "p_band_min": 0, "p_band_max": 2 }
],
"ocean": [
{ "substrate": "seawater", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"deep_ocean": [
{ "substrate": "seawater", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"shallow_sea": [
{ "substrate": "seawater", "t_band_min": 1, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"reef": [
{ "substrate": "seawater", "t_band_min": 3, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"coastal_sea": [
{ "substrate": "seawater", "t_band_min": 1, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"wetland": [
{ "substrate": "peat", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "water", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"swamp": [
{ "substrate": "peat", "t_band_min": 2, "t_band_max": 4, "p_band_min": 3, "p_band_max": 4 },
{ "substrate": "water", "t_band_min": 2, "t_band_max": 4, "p_band_min": 3, "p_band_max": 4 }
],
"marsh": [
{ "substrate": "peat", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "water", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"freshwater": [
{ "substrate": "water", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"lake": [
{ "substrate": "water", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"river": [
{ "substrate": "water", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"mangrove": [
{ "substrate": "peat", "t_band_min": 3, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "sand", "t_band_min": 3, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"coast": [
{ "substrate": "sand", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"intertidal": [
{ "substrate": "sand", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 },
{ "substrate": "seawater", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"estuary": [
{ "substrate": "peat", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 },
{ "substrate": "water", "t_band_min": 1, "t_band_max": 4, "p_band_min": 2, "p_band_max": 4 }
],
"cave": [
{ "substrate": "bedrock", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"spring": [
{ "substrate": "water", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 }
],
"volcanic": [
{ "substrate": "lava", "t_band_min": 0, "t_band_max": 4, "p_band_min": 0, "p_band_max": 4 },
{ "substrate": "bedrock", "t_band_min": 2, "t_band_max": 4, "p_band_min": 0, "p_band_max": 2 }
],
"polar_desert": [
{ "substrate": "ice", "t_band_min": 0, "t_band_max": 0, "p_band_min": 0, "p_band_max": 4 },
{ "substrate": "permafrost", "t_band_min": 0, "t_band_max": 0, "p_band_min": 0, "p_band_max": 1 }
],
"snow": [
{ "substrate": "ice", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 4 },
{ "substrate": "permafrost", "t_band_min": 0, "t_band_max": 1, "p_band_min": 0, "p_band_max": 4 }
],
"mountains": [
{ "substrate": "bedrock", "t_band_min": 0, "t_band_max": 3, "p_band_min": 0, "p_band_max": 4 }
],
"hills": [
{ "substrate": "soil", "t_band_min": 2, "t_band_max": 3, "p_band_min": 2, "p_band_max": 3 },
{ "substrate": "bedrock", "t_band_min": 1, "t_band_max": 3, "p_band_min": 1, "p_band_max": 3 }
]
}
}

View file

@ -336,14 +336,56 @@ fn species_name(biome_id: &str, layer: FloraLayer, structure: Structure, seed: u
// ─── Authored Species JSON Loader ─────────────────────────────────────────────
/// Substrate × climate entry from `substrate_climate[]` in species JSON.
/// Mirrors `mc-ecology::flora_select::SubstrateClimateEntry` (Rust source of
/// truth is the data — mc-flora and mc-ecology each carry a local copy because
/// mc-ecology depends on mc-flora, so the dep cannot run the other way).
/// Inclusive band ranges.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct SubstrateClimateEntry {
pub substrate: String,
pub t_band_min: u8,
pub t_band_max: u8,
pub p_band_min: u8,
pub p_band_max: u8,
}
impl SubstrateClimateEntry {
/// True if this entry's substrate × T_band × P_band ranges intersect `other`.
/// Used to predicate-match a species's substrate_climate[] against a biome's
/// substrate_climate[] candidate cell set.
pub fn intersects(&self, other: &SubstrateClimateEntry) -> bool {
self.substrate == other.substrate
&& self.t_band_min <= other.t_band_max
&& other.t_band_min <= self.t_band_max
&& self.p_band_min <= other.p_band_max
&& other.p_band_min <= self.p_band_max
}
}
/// Loosely typed wrapper that accepts both the canonical
/// `{substrate, t_band_min, t_band_max, p_band_min, p_band_max}` shape and the
/// legacy apex-flora `{biome, min_moisture}` shape (4 tier-10 species). The
/// legacy variant is silently dropped — apex flora aren't procedurally placed.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(untagged)]
enum RawSubstrateClimate {
Canonical(SubstrateClimateEntry),
#[allow(dead_code)]
LegacyApex {
biome: String,
#[serde(default)]
min_moisture: f32,
},
}
/// Raw deserialization shape matching the authored species JSON files.
///
/// Apex tier-10 flora (ancient_sentinel_tree, mind_orchid_colony, bloodwood_grove,
/// ascendant_world_root) omit `biomes` and `traits` (they use `substrate_climate`
/// + `terrain_affinity` and are not procedurally placed by the biome loader).
/// All structural fields therefore default; the biome filter loop will skip any
/// file with empty `biomes`, leaving apex flora as data-only entries that
/// deserialise without panic but are not yet wired into the placement engine.
/// ascendant_world_root) omit `traits` and use the legacy `{biome, min_moisture}`
/// substrate_climate shape. They deserialise without panic but the predicate
/// filter skips them (no canonical substrate_climate entries, no terrain_affinity
/// hits → not a candidate).
#[derive(serde::Deserialize)]
struct AuthoredSpeciesFile {
#[allow(dead_code)]
@ -351,13 +393,22 @@ struct AuthoredSpeciesFile {
name: String,
#[serde(default)]
traits: Vec<String>,
/// Legacy `biomes[]` was stripped from all 149 flora files in p2-52; retained
/// here only so older fixtures and tests that hand-write the field continue
/// to parse and the loader treats it as an additional inclusion path.
#[serde(default)]
biomes: Vec<String>,
/// Newer authored files use `terrain_affinity` in place of `biomes`. Treated
/// as a fallback when `biomes` is empty so both schemas resolve to the same
/// biome-membership predicate.
/// Procedural biome aliases (e.g. `"savanna"`, `"plains"`). Used as a
/// secondary inclusion path when the substrate_climate predicate alone would
/// be too restrictive (e.g. biomes the Whittaker classifier does not emit).
#[serde(default)]
terrain_affinity: Vec<String>,
/// Canonical p2-52 placement predicate: list of (substrate, T_band range,
/// P_band range) tuples in which the species can appear. Parsed as the
/// untagged enum so legacy apex entries are tolerated without aborting the
/// load.
#[serde(default)]
substrate_climate: Vec<RawSubstrateClimate>,
#[serde(default)]
#[allow(dead_code)]
quality_tier: i32,
@ -375,6 +426,80 @@ struct AuthoredSpeciesFile {
fire_resistance: f32,
}
impl AuthoredSpeciesFile {
/// Return the canonical (typed) substrate_climate entries, dropping legacy
/// apex `{biome, min_moisture}` shape silently.
fn canonical_substrate_climate(&self) -> Vec<&SubstrateClimateEntry> {
self.substrate_climate
.iter()
.filter_map(|e| match e {
RawSubstrateClimate::Canonical(c) => Some(c),
RawSubstrateClimate::LegacyApex { .. } => None,
})
.collect()
}
}
// ─── Biome → substrate_climate predicate map (JSON tunable) ─────────────────
/// JSON tunable: maps a procedural biome_id to its set of valid
/// (substrate, T_band range, P_band range) cells. Authored at
/// `public/games/age-of-dwarves/data/flora/biome_substrate_climate.json`.
///
/// Used to predicate-match an authored species's `substrate_climate[]` ranges
/// against the cells in which the biome is rendered. A species is a candidate
/// for biome `b` iff at least one of its substrate_climate entries intersects
/// at least one cell of `b`'s entry list.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BiomeSubstrateClimateMap {
pub biomes: std::collections::HashMap<String, Vec<SubstrateClimateEntry>>,
}
impl BiomeSubstrateClimateMap {
/// Load from JSON text. Errors are returned to the caller; see
/// `load_default()` for the on-disk path resolver.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Resolve and load the workspace-relative default tunable file.
/// Returns `None` if the file is absent or unreadable — callers degrade to
/// the `terrain_affinity[]`-only path.
pub fn load_default() -> Option<Self> {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.ancestors().nth(2)?;
let path = workspace_root
.join("public/games/age-of-dwarves/data/flora/biome_substrate_climate.json");
let text = std::fs::read_to_string(&path).ok()?;
Self::from_json(&text).ok()
}
/// Entries for a biome (empty slice if unknown).
pub fn cells_for(&self, biome_id: &str) -> &[SubstrateClimateEntry] {
self.biomes
.get(biome_id)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// True if any of `species_entries` intersects any cell registered for
/// `biome_id`. Returns false when the biome is unknown OR the species has
/// no canonical substrate_climate entries.
pub fn species_matches_biome(
&self,
biome_id: &str,
species_entries: &[&SubstrateClimateEntry],
) -> bool {
let biome_cells = self.cells_for(biome_id);
if biome_cells.is_empty() || species_entries.is_empty() {
return false;
}
species_entries
.iter()
.any(|sp| biome_cells.iter().any(|bc| sp.intersects(bc)))
}
}
fn tag_to_layer(tag: &str) -> Option<FloraLayer> {
match tag {
"layer_canopy" => Some(FloraLayer::Canopy),
@ -481,10 +606,34 @@ fn parse_trait_set(tags: &[String]) -> Option<FloraTraitSet> {
/// Load authored flora species from JSON files in `species_dir` that are
/// compatible with `biome_id`. Species with invalid trait combinations are
/// silently skipped. Assigns sequential numeric IDs starting from `start_id`.
///
/// Inclusion predicate (post-p2-52, post-p2-63):
/// 1. species's `substrate_climate[]` (typed entries) intersects the biome's
/// registered cells in `biome_substrate_climate.json` (primary path,
/// substrate-aware), OR
/// 2. species's `terrain_affinity[]` contains `biome_id` (secondary path,
/// covers procedural biome aliases the substrate_climate map does not
/// register), OR
/// 3. species's legacy `biomes[]` contains `biome_id` (tertiary path,
/// retained for hand-written test fixtures only — production species
/// files no longer carry this field).
pub fn load_authored_species_for_biome(
biome_id: &str,
species_dir: &std::path::Path,
start_id: u32,
) -> Vec<FloraSpecies> {
let biome_map = BiomeSubstrateClimateMap::load_default();
load_authored_species_for_biome_with_map(biome_id, species_dir, start_id, biome_map.as_ref())
}
/// Variant of `load_authored_species_for_biome` that accepts a pre-loaded
/// biome→substrate_climate predicate map. Exists so callers iterating over many
/// biomes pay the JSON parse cost once.
pub fn load_authored_species_for_biome_with_map(
biome_id: &str,
species_dir: &std::path::Path,
start_id: u32,
biome_map: Option<&BiomeSubstrateClimateMap>,
) -> Vec<FloraSpecies> {
let entries = match std::fs::read_dir(species_dir) {
Ok(e) => e,
@ -510,11 +659,14 @@ pub fn load_authored_species_for_biome(
Err(_) => continue,
};
// Filter: species must list this biome_id in its biomes array
// (legacy schema) or terrain_affinity array (current schema).
let listed_in_biomes = raw.biomes.iter().any(|b| b == biome_id);
// Predicate match (see fn-level docs).
let canonical = raw.canonical_substrate_climate();
let matches_substrate = biome_map
.map(|m| m.species_matches_biome(biome_id, &canonical))
.unwrap_or(false);
let listed_in_terrain = raw.terrain_affinity.iter().any(|b| b == biome_id);
if !listed_in_biomes && !listed_in_terrain {
let listed_in_biomes = raw.biomes.iter().any(|b| b == biome_id);
if !matches_substrate && !listed_in_terrain && !listed_in_biomes {
continue;
}
@ -607,7 +759,13 @@ pub fn generate_flora_for_biome(biome_id: &str, base_seed: u64, start_id: u32) -
let species_dir = workspace_root.join("public/resources/ecology/flora/species");
if species_dir.is_dir() {
let authored_start = start_id + species.len() as u32;
let authored = load_authored_species_for_biome(biome_id, &species_dir, authored_start);
let biome_map = BiomeSubstrateClimateMap::load_default();
let authored = load_authored_species_for_biome_with_map(
biome_id,
&species_dir,
authored_start,
biome_map.as_ref(),
);
for authored_sp in authored {
let key = trait_combo_key(&authored_sp.traits);
if !seen_trait_hash.contains(&key) {