diff --git a/public/games/age-of-dwarves/data/flora/biome_substrate_climate.json b/public/games/age-of-dwarves/data/flora/biome_substrate_climate.json new file mode 100644 index 00000000..e219b50d --- /dev/null +++ b/public/games/age-of-dwarves/data/flora/biome_substrate_climate.json @@ -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 } + ] + } +} diff --git a/src/simulator/crates/mc-flora/src/generation.rs b/src/simulator/crates/mc-flora/src/generation.rs index 359d9f61..6000676b 100644 --- a/src/simulator/crates/mc-flora/src/generation.rs +++ b/src/simulator/crates/mc-flora/src/generation.rs @@ -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, + /// 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, - /// 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, + /// 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, #[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>, +} + +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 { + 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 { + 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 { match tag { "layer_canopy" => Some(FloraLayer::Canopy), @@ -481,10 +606,34 @@ fn parse_trait_set(tags: &[String]) -> Option { /// 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 { + 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 { 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) {