feat(@projects/@magic-civilization): ✨ add biome climate substrate rules
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2fe49402de
commit
c1358c9d2d
2 changed files with 314 additions and 13 deletions
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue