feat(@projects/@magic-civilization): add apex fauna combat & cognitive profiles

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-04 00:36:30 -04:00
parent 5ed3fedcff
commit 438f2b8b4e
11 changed files with 737 additions and 43 deletions

View file

@ -139,3 +139,34 @@ Total grazing = Σ(individual_rate × population × social_multiplier)
| colony | 0.3 per member | Mostly underground, minimal surface impact |
A herd of 8 medium herbivores: 8 × 0.02 × 1.2 = 0.192 undergrowth/turn (38% of a 0.50 undergrowth forest — unsustainable, will crash).
## Apex / Boss Profile Schema (p1-58)
Tier-10 fauna (`ancient_red_dragon`, `ancient_white_dragon`, `ancient_green_dragon`)
and apex tier-10 sentient flora (`ancient_sentinel_tree`, `mind_orchid_colony`,
`bloodwood_grove`, `ascendant_world_root`) carry an extended profile block
deserialised by `mc-ecology::Species`:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `combat_profile` | object | optional | `{ hp, strength, dexterity, constitution, attack_type?, range?, defense?, breath_weapon? }` |
| `combat_profile.breath_weapon` | object | optional | `{ damage, area_radius, cooldown_turns }` |
| `cognitive_profile` | object | optional | `{ intelligence (110), hostility, can_hold_grudge, grudge_memory_turns, pack_behavior? }` |
| `cognitive_profile.pack_behavior` | object | optional | `{ pack_size_min, pack_size_max, coordinated_attack }` |
| `terrain_affinity` | string[] | optional | terrain IDs (e.g. `["mountains", "volcano", "cliff"]`) — flat array; resolves against `TerrainKind` registry at use sites |
| `loot_table` | object | optional | `{ legendary[], rare[], common[] }` — string IDs |
| `devastation` | object | optional | `{ auto_defeat_tier_at_or_below, description }` — units at or below the tier threshold are destroyed without a roll |
| `food_consumption_per_turn` | u32 | optional | raw biome food drained per turn (apex predators stake out territory) |
Grudge rule: `cognitive_profile.intelligence >= 3 → can_hold_grudge: true`,
`grudge_memory_turns = intelligence × 10`. Enforced by
`Species::can_hold_grudge()` and `Species::computed_grudge_memory()`.
True-dragon lineage (`true_dragon`) is exempt from the T7 real-species cap
(see `species::tests::real_species_are_tier_7_or_below`); these creatures are
fantasy boss fauna by definition.
Apex flora carry the same `combat_profile`/`cognitive_profile`/`loot_table`
shape but are not yet placed by the procedural flora generator — those fields
are validated for parse-correctness only at this milestone (see
`mc-flora::generation::tests::all_authored_flora_species_deserialize`).

View file

@ -4,6 +4,7 @@
//! building schema (sprite, encyclopedia, etc.) lives in JSON and is consumed
//! directly by GDScript via the DataLoader dictionary path.
use mc_core::{BuildingId, GppType, GreatWorkType, SpecialistId};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
@ -30,6 +31,75 @@ pub struct AdjacencyRule {
pub yield_bonus: AdjacencyYieldBonus,
}
/// One entry of a building's `effects` array.
///
/// The simulator branches directly on the variants relevant to per-turn
/// processing (Great Person Points, Great Work slot capacity). Every other
/// authored effect type — and there are ~140 of them, see
/// `public/games/age-of-dwarves/docs/BUILDING_SCHEMA.md` — is preserved
/// losslessly via `Other` until the matching domain crate consumes it.
///
/// JSON shape: `{ "type": "<snake_case>", "value": <number> }`. Effects
/// not yet wired into the simulator land in `Other` and their `value` is
/// dropped — by design: domain crates opt in by promoting their effect
/// type to a typed variant here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BuildingEffect {
// ── Great Person Point yields (per-turn, accumulated by `mc-city::gpp`) ──
GppWriting { value: i32 },
GppMusic { value: i32 },
GppArt { value: i32 },
GppStatuary { value: i32 },
GppScholarship { value: i32 },
GppTrade { value: i32 },
GppEngineering { value: i32 },
// ── Great Work slot capacity (per-building, occupied at GP activation) ──
GreatWorkSlotsWriting { value: i32 },
GreatWorkSlotsMusic { value: i32 },
GreatWorkSlotsArt { value: i32 },
GreatWorkSlotsStatuary { value: i32 },
/// Catch-all for the ~140 authored effect types whose simulator hook
/// lives outside `mc-city`. The `value` field (and any siblings) are
/// silently dropped — downstream crates (combat, magic, economy, ...)
/// will opt in by adding their own typed variants in this enum.
#[serde(other)]
Other,
}
impl BuildingEffect {
/// If this effect is a Great Person Point yield, return its channel and
/// per-turn value.
#[must_use]
pub fn as_gpp(&self) -> Option<(GppType, i32)> {
match *self {
BuildingEffect::GppWriting { value } => Some((GppType::Writing, value)),
BuildingEffect::GppMusic { value } => Some((GppType::Music, value)),
BuildingEffect::GppArt { value } => Some((GppType::Art, value)),
BuildingEffect::GppStatuary { value } => Some((GppType::Statuary, value)),
BuildingEffect::GppScholarship { value } => Some((GppType::Scholarship, value)),
BuildingEffect::GppTrade { value } => Some((GppType::Trade, value)),
BuildingEffect::GppEngineering { value } => Some((GppType::Engineering, value)),
_ => None,
}
}
/// If this effect is a Great Work slot capacity, return its category and
/// the number of slots provided.
#[must_use]
pub fn as_great_work_slots(&self) -> Option<(GreatWorkType, i32)> {
match *self {
BuildingEffect::GreatWorkSlotsWriting { value } => Some((GreatWorkType::Writing, value)),
BuildingEffect::GreatWorkSlotsMusic { value } => Some((GreatWorkType::Music, value)),
BuildingEffect::GreatWorkSlotsArt { value } => Some((GreatWorkType::Art, value)),
BuildingEffect::GreatWorkSlotsStatuary { value } => Some((GreatWorkType::Statuary, value)),
_ => None,
}
}
}
/// Deserializes `wonder_type` from JSON, treating both `null` and absent as
/// `WonderType::None`.
fn de_wonder_type<'de, D: Deserializer<'de>>(d: D) -> Result<WonderType, D::Error> {
@ -73,9 +143,10 @@ pub enum WonderType {
/// Simulation-relevant projection of a building JSON entry.
///
/// The full schema (name, cost, effects, sprite, encyclopedia) lives in JSON
/// and is read by GDScript. This struct captures only the fields the Rust
/// simulation needs to enforce tile-placement rules and wonder uniqueness.
/// The full schema (name, cost, sprite, encyclopedia) lives in JSON and is
/// read by GDScript. This struct captures only the fields the Rust
/// simulation needs to enforce tile-placement, wonder uniqueness, specialist
/// allocation, and per-turn yields.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BuildingDef {
pub id: String,
@ -103,14 +174,21 @@ pub struct BuildingDef {
pub wonder_type: WonderType,
/// Specialist IDs that may be employed in this building.
/// Each ID corresponds to an entry in `public/resources/specialists/specialists.json`.
/// Each ID corresponds to an entry in
/// `public/resources/specialists/specialists.json`.
#[serde(default)]
pub specialist_slots: Vec<String>,
pub specialist_slots: Vec<SpecialistId>,
/// For national wonders: every owned city must contain all buildings in
/// this list before the national wonder can be queued.
#[serde(default)]
pub requires_buildings_all_cities: Vec<String>,
pub requires_buildings_all_cities: Vec<BuildingId>,
/// Authored effect entries. Includes per-turn GPP yields, Great Work slot
/// capacities, and ~140 other effect types consumed by the corresponding
/// domain crates. Unknown variants round-trip via `BuildingEffect::Other`.
#[serde(default)]
pub effects: Vec<BuildingEffect>,
/// Minimum episode number required to build or appear in the game.
/// `None` means available from episode 1 (no gate).
@ -155,6 +233,30 @@ impl BuildingDef {
.iter()
.all(|req| present.contains(req.as_str()))
}
/// Sum the per-turn Great Person Points yielded by this building for the
/// given channel.
#[must_use]
pub fn gpp_yield(&self, channel: GppType) -> i32 {
self.effects
.iter()
.filter_map(BuildingEffect::as_gpp)
.filter(|(c, _)| *c == channel)
.map(|(_, v)| v)
.sum()
}
/// Sum the Great Work slot capacity provided by this building for the
/// given category.
#[must_use]
pub fn great_work_slots(&self, category: GreatWorkType) -> i32 {
self.effects
.iter()
.filter_map(BuildingEffect::as_great_work_slots)
.filter(|(c, _)| *c == category)
.map(|(_, v)| v)
.sum()
}
}
/// Registry of building definitions, keyed by id.
@ -207,6 +309,7 @@ mod tests {
wonder_type: WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}
@ -220,6 +323,7 @@ mod tests {
wonder_type: WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}
@ -234,6 +338,7 @@ mod tests {
wonder_type: WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}
@ -249,6 +354,7 @@ mod tests {
assert!(!def.placement_tile_required);
assert!(def.placeable_on.is_empty());
assert!(def.adjacency.is_empty());
assert!(def.effects.is_empty());
}
#[test]
@ -299,6 +405,7 @@ mod tests {
wonder_type: WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
};
let json = serde_json::to_string(&def).unwrap();
@ -374,8 +481,11 @@ mod tests {
let json = r#"{"id": "saga_chronicle", "wonder_type": "national", "requires_buildings_all_cities": ["saga_arena"], "specialist_slots": ["saga_writer"]}"#;
let def: BuildingDef = serde_json::from_str(json).unwrap();
assert_eq!(def.wonder_type, WonderType::National);
assert_eq!(def.requires_buildings_all_cities, vec!["saga_arena"]);
assert_eq!(def.specialist_slots, vec!["saga_writer"]);
assert_eq!(
def.requires_buildings_all_cities,
vec![BuildingId::new("saga_arena")]
);
assert_eq!(def.specialist_slots, vec![SpecialistId::new("saga_writer")]);
}
#[test]
@ -410,7 +520,8 @@ mod tests {
adjacency: vec![],
wonder_type: WonderType::National,
specialist_slots: vec![],
requires_buildings_all_cities: vec!["saga_arena".into()],
requires_buildings_all_cities: vec![BuildingId::new("saga_arena")],
effects: vec![],
min_episode: None,
};
assert!(def.is_national_wonder_buildable(&["saga_arena", "forge", "library"]));
@ -425,7 +536,8 @@ mod tests {
adjacency: vec![],
wonder_type: WonderType::National,
specialist_slots: vec![],
requires_buildings_all_cities: vec!["saga_arena".into()],
requires_buildings_all_cities: vec![BuildingId::new("saga_arena")],
effects: vec![],
min_episode: None,
};
assert!(!def.is_national_wonder_buildable(&["forge", "library"]));
@ -441,6 +553,7 @@ mod tests {
wonder_type: WonderType::World,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
};
// World wonder — is_national_wonder_buildable always returns true
@ -448,6 +561,99 @@ mod tests {
assert!(def.is_national_wonder_buildable(&[]));
}
// ── New typed effect-array fields (p1-56) ────────────────────────────────
#[test]
fn test_building_deserialises_new_fields() {
// saga_arena.json: gpp_writing + great_work_slots_writing + specialist
let saga_arena = r#"{
"id": "saga_arena",
"wonder_type": null,
"specialist_slots": ["saga_writer"],
"effects": [
{ "type": "culture", "value": 3 },
{ "type": "happiness", "value": 1 },
{ "type": "great_work_slots_writing", "value": 1 },
{ "type": "gpp_writing", "value": 1 }
]
}"#;
let def: BuildingDef = serde_json::from_str(saga_arena).unwrap();
assert_eq!(def.gpp_yield(GppType::Writing), 1);
assert_eq!(def.gpp_yield(GppType::Music), 0);
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 1);
assert_eq!(def.specialist_slots, vec![SpecialistId::new("saga_writer")]);
// saga_chronicle.json: national wonder, multi-effect
let saga_chronicle = r#"{
"id": "saga_chronicle",
"wonder_type": "national",
"requires_buildings_all_cities": ["saga_arena"],
"specialist_slots": ["saga_writer"],
"effects": [
{ "type": "great_person_rate_percent", "value": 0.25 },
{ "type": "culture", "value": 4 },
{ "type": "great_work_slots_writing", "value": 2 },
{ "type": "gpp_writing", "value": 2 }
]
}"#;
let def: BuildingDef = serde_json::from_str(saga_chronicle).unwrap();
assert_eq!(def.wonder_type, WonderType::National);
assert_eq!(def.gpp_yield(GppType::Writing), 2);
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 2);
// The unsupported effect (great_person_rate_percent + culture) lands
// in the Other variant — counted but not branched on by mc-city yet.
let other_count = def
.effects
.iter()
.filter(|e| matches!(e, BuildingEffect::Other))
.count();
assert_eq!(other_count, 2);
}
#[test]
fn gpp_yield_sums_across_multiple_entries() {
let json = r#"{
"id": "test",
"effects": [
{ "type": "gpp_writing", "value": 2 },
{ "type": "gpp_writing", "value": 3 },
{ "type": "gpp_music", "value": 1 }
]
}"#;
let def: BuildingDef = serde_json::from_str(json).unwrap();
assert_eq!(def.gpp_yield(GppType::Writing), 5);
assert_eq!(def.gpp_yield(GppType::Music), 1);
assert_eq!(def.gpp_yield(GppType::Art), 0);
}
#[test]
fn great_work_slots_sums_across_multiple_entries() {
let json = r#"{
"id": "test",
"effects": [
{ "type": "great_work_slots_statuary", "value": 1 },
{ "type": "great_work_slots_statuary", "value": 2 }
]
}"#;
let def: BuildingDef = serde_json::from_str(json).unwrap();
assert_eq!(def.great_work_slots(GreatWorkType::Statuary), 3);
assert_eq!(def.great_work_slots(GreatWorkType::Writing), 0);
}
#[test]
fn unknown_effect_types_round_trip_as_other() {
let json = r#"{
"id": "test",
"effects": [
{ "type": "this_effect_does_not_exist", "value": 99 },
{ "type": "another_unknown", "value": "stringy" }
]
}"#;
let def: BuildingDef = serde_json::from_str(json).unwrap();
assert_eq!(def.effects.len(), 2);
assert!(def.effects.iter().all(|e| matches!(e, BuildingEffect::Other)));
}
// ── All authored buildings deserialize cleanly ────────────────────────────
#[test]

View file

@ -110,6 +110,7 @@ mod tests {
wonder_type: crate::building::WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}
@ -123,6 +124,7 @@ mod tests {
wonder_type: crate::building::WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}
@ -139,6 +141,7 @@ mod tests {
wonder_type: crate::building::WonderType::None,
specialist_slots: vec![],
requires_buildings_all_cities: vec![],
effects: vec![],
min_episode: None,
}
}

View file

@ -0,0 +1,131 @@
//! Great Person Point types and Great Work types — closed sets the simulator
//! branches on directly when accumulating per-turn GPP and assigning Great
//! Works to building slots.
//!
//! These are closed enums (not newtype strings) because the simulator must
//! match-exhaustively on them. New variants require a code + content change
//! together.
use serde::{Deserialize, Serialize};
/// Per-turn Great Person Point yield channel.
///
/// Authored on buildings as `gpp_<lowercase>` effect entries (e.g.
/// `"gpp_writing"`, `"gpp_scholarship"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GppType {
Writing,
Music,
Art,
Statuary,
Scholarship,
Trade,
Engineering,
}
impl GppType {
pub const ALL: [GppType; 7] = [
GppType::Writing,
GppType::Music,
GppType::Art,
GppType::Statuary,
GppType::Scholarship,
GppType::Trade,
GppType::Engineering,
];
/// String key used in building effect entries (`gpp_writing` etc.).
#[must_use]
pub fn effect_key(self) -> &'static str {
match self {
GppType::Writing => "gpp_writing",
GppType::Music => "gpp_music",
GppType::Art => "gpp_art",
GppType::Statuary => "gpp_statuary",
GppType::Scholarship => "gpp_scholarship",
GppType::Trade => "gpp_trade",
GppType::Engineering => "gpp_engineering",
}
}
}
/// Great Work category. Each great work occupies one slot of the matching
/// type in a building.
///
/// Authored on buildings as `great_work_slots_<lowercase>` effect entries.
/// `Scholarship`, `Trade`, `Engineering` GPP types do NOT have a matching
/// great work category — those archetypes produce other actions (free tech,
/// production hurry, gold) instead of spawning a great work.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GreatWorkType {
Writing,
Music,
Art,
Statuary,
}
impl GreatWorkType {
pub const ALL: [GreatWorkType; 4] = [
GreatWorkType::Writing,
GreatWorkType::Music,
GreatWorkType::Art,
GreatWorkType::Statuary,
];
/// String key used in building effect entries (`great_work_slots_writing` etc.).
#[must_use]
pub fn slot_effect_key(self) -> &'static str {
match self {
GreatWorkType::Writing => "great_work_slots_writing",
GreatWorkType::Music => "great_work_slots_music",
GreatWorkType::Art => "great_work_slots_art",
GreatWorkType::Statuary => "great_work_slots_statuary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpp_type_serialises_snake_case() {
let json = serde_json::to_string(&GppType::Scholarship).unwrap();
assert_eq!(json, "\"scholarship\"");
let back: GppType = serde_json::from_str(&json).unwrap();
assert_eq!(back, GppType::Scholarship);
}
#[test]
fn great_work_type_serialises_snake_case() {
let json = serde_json::to_string(&GreatWorkType::Statuary).unwrap();
assert_eq!(json, "\"statuary\"");
}
#[test]
fn gpp_effect_keys_match_authored_inventory() {
// Sanity: keys match what `public/resources/buildings/*.json` uses.
assert_eq!(GppType::Writing.effect_key(), "gpp_writing");
assert_eq!(GppType::Engineering.effect_key(), "gpp_engineering");
}
#[test]
fn great_work_slot_keys_match_authored_inventory() {
assert_eq!(
GreatWorkType::Writing.slot_effect_key(),
"great_work_slots_writing"
);
assert_eq!(
GreatWorkType::Statuary.slot_effect_key(),
"great_work_slots_statuary"
);
}
#[test]
fn all_arrays_cover_every_variant() {
assert_eq!(GppType::ALL.len(), 7);
assert_eq!(GreatWorkType::ALL.len(), 4);
}
}

View file

@ -0,0 +1,105 @@
//! Typed string newtypes for content identifiers authored in JSON game packs.
//!
//! These prevent silent string-mixing bugs (e.g. passing a `BuildingId` where
//! a `SpecialistId` is expected) without coupling Rust to closed enums of
//! authored content. JSON serialisation is transparent — each newtype
//! round-trips as a bare string.
//!
//! For closed sets that the simulator branches on directly (`GppType`,
//! `GreatWorkType`), see the sibling `gpp` and `great_works` modules.
use serde::{Deserialize, Serialize};
macro_rules! string_newtype {
($name:ident, $doc:literal) => {
#[doc = $doc]
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub String);
impl $name {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
};
}
string_newtype!(BuildingId, "Snake_case building identifier (e.g. `\"saga_arena\"`).");
string_newtype!(SpecialistId, "Snake_case specialist class identifier (e.g. `\"saga_writer\"`).");
string_newtype!(
GreatPersonClass,
"Snake_case great person archetype identifier (e.g. `\"great_writer\"`)."
);
string_newtype!(
HarvestPolicyId,
"Snake_case harvest policy identifier (e.g. `\"replenish\"`)."
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn building_id_round_trips_as_bare_string() {
let id = BuildingId::new("saga_arena");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"saga_arena\"");
let back: BuildingId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
#[test]
fn specialist_id_vec_round_trips() {
let v = vec![SpecialistId::new("saga_writer"), SpecialistId::new("forge_chanter")];
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, "[\"saga_writer\",\"forge_chanter\"]");
let back: Vec<SpecialistId> = serde_json::from_str(&json).unwrap();
assert_eq!(back, v);
}
#[test]
fn harvest_policy_id_displays_as_inner() {
let id = HarvestPolicyId::new("replenish");
assert_eq!(id.to_string(), "replenish");
}
#[test]
fn great_person_class_from_str() {
let id: GreatPersonClass = "great_writer".into();
assert_eq!(id.as_str(), "great_writer");
}
}

View file

@ -5,13 +5,19 @@ pub mod building_state;
pub mod collectibles;
pub mod formation;
pub mod gd_compat;
pub mod gpp;
pub mod grid;
pub mod ids;
pub mod improvement;
pub mod multi_turn_action;
pub mod perf;
pub mod player;
pub mod resources;
pub mod tech;
pub mod wonder;
pub use gpp::{GppType, GreatWorkType};
pub use ids::{BuildingId, GreatPersonClass, HarvestPolicyId, SpecialistId};
pub use player::{HexCoord, PlayerPrologue};
pub use tech::TechDomain;
pub use wonder::WonderId;

View file

@ -0,0 +1,110 @@
//! Tech categorisation primitives shared across the workspace.
//!
//! `TechDomain` is the typed, 10-value enumeration used by every consumer
//! (`mc-tech`, UI bridges, analysis surfaces) to group techs for display
//! and player analysis. It is a metadata layer — the simulator's primary
//! organisational axis remains `pillar` (string) per design.
//!
//! Variants mirror the canonical PascalCase strings authored in
//! `public/resources/techs/*.json`. "All" is a UI sentinel only and is
//! never authored on a tech, so it has no variant here.
use serde::{Deserialize, Serialize};
/// One of the 10 canonical tech domain categories.
///
/// Authored as a PascalCase string in tech JSON (e.g. `"Agriculture"`).
/// Serde round-trips PascalCase → variant by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum TechDomain {
Military,
Economy,
Industry,
Agriculture,
Governance,
Culture,
Science,
Exploration,
Engineering,
Medicine,
}
impl TechDomain {
/// All 10 variants in canonical authoring order. Stable for iteration
/// (e.g. dashboard column order, BTreeMap seeding).
pub const ALL: [TechDomain; 10] = [
TechDomain::Military,
TechDomain::Economy,
TechDomain::Industry,
TechDomain::Agriculture,
TechDomain::Governance,
TechDomain::Culture,
TechDomain::Science,
TechDomain::Exploration,
TechDomain::Engineering,
TechDomain::Medicine,
];
/// PascalCase string form, matching the authored JSON value.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
TechDomain::Military => "Military",
TechDomain::Economy => "Economy",
TechDomain::Industry => "Industry",
TechDomain::Agriculture => "Agriculture",
TechDomain::Governance => "Governance",
TechDomain::Culture => "Culture",
TechDomain::Science => "Science",
TechDomain::Exploration => "Exploration",
TechDomain::Engineering => "Engineering",
TechDomain::Medicine => "Medicine",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_json_pascal_case() {
for variant in TechDomain::ALL {
let json = serde_json::to_string(&variant).unwrap();
let back: TechDomain = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
// Serialised form is the PascalCase variant name in quotes.
assert_eq!(json, format!("\"{}\"", variant.as_str()));
}
}
#[test]
fn all_contains_each_variant_exactly_once() {
let mut seen = std::collections::BTreeSet::new();
for v in TechDomain::ALL {
assert!(seen.insert(v), "duplicate variant in ALL: {v:?}");
}
assert_eq!(seen.len(), 10);
}
#[test]
fn deserialises_authored_json_strings() {
for name in [
"Military", "Economy", "Industry", "Agriculture", "Governance",
"Culture", "Science", "Exploration", "Engineering", "Medicine",
] {
let quoted = format!("\"{name}\"");
let parsed: TechDomain = serde_json::from_str(&quoted)
.unwrap_or_else(|e| panic!("failed to parse {name}: {e}"));
assert_eq!(parsed.as_str(), name);
}
}
#[test]
fn rejects_unknown_domain_values() {
let bad = serde_json::from_str::<TechDomain>("\"All\"");
assert!(bad.is_err(), "'All' is a UI sentinel, must not deserialise");
let bad = serde_json::from_str::<TechDomain>("\"agriculture\"");
assert!(bad.is_err(), "lowercase variants must be rejected");
}
}

View file

@ -1128,6 +1128,12 @@ mod tests {
if sp.lineage.starts_with("freefolk_") && sp.ecology_tier >= 8 {
continue;
}
// True dragons (ancient_red/white/green) are tier-10 boss fauna authored
// for p1-58 ecology cognition; they are fantasy creatures by definition
// and exempt from the T7 cap.
if sp.lineage == "true_dragon" {
continue;
}
assert!(
sp.ecology_tier <= 7,
"real species {key} (lineage={}) should be T7 or below, got T{}",

View file

@ -337,20 +337,36 @@ fn species_name(biome_id: &str, layer: FloraLayer, structure: Structure, seed: u
// ─── Authored Species JSON Loader ─────────────────────────────────────────────
/// 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.
#[derive(serde::Deserialize)]
struct AuthoredSpeciesFile {
#[allow(dead_code)]
id: String,
name: String,
#[serde(default)]
traits: Vec<String>,
#[serde(default)]
biomes: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
quality_tier: i32,
#[serde(default)]
canopy_contribution: f32,
#[serde(default)]
undergrowth_contribution: f32,
#[serde(default)]
fungi_contribution: f32,
#[serde(default)]
growth_rate: f32,
#[serde(default)]
drought_tolerance: f32,
#[serde(default)]
fire_resistance: f32,
}
@ -760,6 +776,56 @@ mod tests {
}
}
/// p1-58: every authored flora species file must deserialize without panic,
/// including apex tier-10 sentient flora (ancient_sentinel_tree, mind_orchid_colony,
/// bloodwood_grove, ascendant_world_root) which omit `biomes`/`traits` and carry
/// extended profile fields (combat_profile, cognitive_profile, terrain_affinity,
/// loot_table). Profile fields are dropped at this layer (FloraSpecies does not
/// yet hold them) but parsing must not fail.
#[test]
fn all_authored_flora_species_deserialize() {
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("workspace root")
.to_path_buf();
let dir = workspace_root.join("public/resources/ecology/flora/species");
let mut total = 0usize;
let mut failures: Vec<String> = Vec::new();
let mut apex_ok = 0usize;
for entry in std::fs::read_dir(&dir).expect("flora species dir readable") {
let entry = entry.expect("dir entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
total += 1;
let text = std::fs::read_to_string(&path).expect("flora json readable");
match serde_json::from_str::<AuthoredSpeciesFile>(&text) {
Ok(raw) => {
if raw.biomes.is_empty() {
// Apex / structural flora — no biome filter; validates the
// optional-field path on AuthoredSpeciesFile.
apex_ok += 1;
}
}
Err(e) => failures.push(format!("{}: {}", path.display(), e)),
}
}
assert!(total >= 149, "expected ≥149 flora species files, found {total}");
assert!(
failures.is_empty(),
"{} flora species failed to deserialize:\n{}",
failures.len(),
failures.join("\n")
);
// The four apex tier-10 sentient flora should be among the no-biomes set.
assert!(
apex_ok >= 4,
"expected ≥4 apex flora (no `biomes` field) to deserialize, got {apex_ok}"
);
}
#[test]
fn all_generated_traits_are_valid() {
for biome in &[

View file

@ -4,8 +4,9 @@
//! The graph is immutable after construction; per-player mutation happens
//! in [`super::PlayerTechState`].
use mc_core::TechDomain;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
/// Accept both integer and float JSON numbers as u32 (GDScript JSON.stringify emits floats).
fn de_u32_from_num<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
@ -65,12 +66,12 @@ pub struct TechDefinition {
pub description: String,
#[serde(default)]
pub pillar: String,
/// One of 10 canonical domain categories for UI grouping and analysis:
/// Military / Economy / Industry / Agriculture / Governance / Culture /
/// Science / Exploration / Engineering / Medicine.
/// Never "All" — that is a UI sentinel only, never authored on a tech.
/// One of 10 canonical domain categories for UI grouping and analysis
/// (see [`mc_core::TechDomain`]). Authored as a PascalCase string in
/// every tech JSON; absent only in synthetic test fixtures.
/// Never `"All"` — that is a UI sentinel only, never authored on a tech.
#[serde(default)]
pub domain: String,
pub domain: Option<TechDomain>,
#[serde(default)]
pub school: Option<String>,
#[serde(default = "default_era", deserialize_with = "de_u32_from_num")]
@ -242,6 +243,39 @@ impl TechWeb {
matched.iter().map(|t| t.id.as_str()).collect()
}
/// Returns tech IDs grouped by [`TechDomain`], deterministic iteration
/// (BTreeMap ordering by `TechDomain` derived `Ord`). Tech IDs within
/// each domain are sorted by `(tier, id)` for stable display.
/// Techs whose `domain` is `None` are skipped — only authored data has
/// a domain, and authoring is enforced by the schema test below.
#[must_use]
pub fn techs_by_domain_map(&self) -> BTreeMap<TechDomain, Vec<&str>> {
let mut out: BTreeMap<TechDomain, Vec<&TechDefinition>> = BTreeMap::new();
for tech in self.techs.values() {
if let Some(domain) = tech.domain {
out.entry(domain).or_default().push(tech);
}
}
out.into_iter()
.map(|(domain, mut defs)| {
defs.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id)));
(domain, defs.into_iter().map(|d| d.id.as_str()).collect())
})
.collect()
}
/// Returns tech IDs belonging to `domain`, sorted by `(tier, id)`.
#[must_use]
pub fn techs_by_domain(&self, domain: TechDomain) -> Vec<&str> {
let mut matched: Vec<&TechDefinition> = self
.techs
.values()
.filter(|t| t.domain == Some(domain))
.collect();
matched.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| a.id.cmp(&b.id)));
matched.iter().map(|t| t.id.as_str()).collect()
}
/// All unique pillar names found in the tech data.
pub fn pillars(&self) -> Vec<&str> {
let mut seen = std::collections::HashSet::new();
@ -368,36 +402,35 @@ impl TechWeb {
mod tests {
use super::*;
/// The 10 canonical domain values. "All" is a UI sentinel — never authored.
const VALID_DOMAINS: &[&str] = &[
"Military",
"Economy",
"Industry",
"Agriculture",
"Governance",
"Culture",
"Science",
"Exploration",
"Engineering",
"Medicine",
];
#[test]
fn domain_field_round_trips_via_json() {
let json = r#"{"id": "smelting", "name": "Smelting", "domain": "Industry"}"#;
let def: TechDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.domain, "Industry");
assert_eq!(def.domain, Some(TechDomain::Industry));
// And the round-trip back to JSON preserves the PascalCase string.
let reser = serde_json::to_value(&def).unwrap();
assert_eq!(reser["domain"], serde_json::json!("Industry"));
}
#[test]
fn domain_field_defaults_to_empty_string_when_absent() {
fn domain_field_defaults_to_none_when_absent() {
let json = r#"{"id": "smelting", "name": "Smelting"}"#;
let def: TechDefinition = serde_json::from_str(json).unwrap();
assert_eq!(def.domain, "");
assert_eq!(def.domain, None);
}
#[test]
fn all_authored_techs_have_valid_non_empty_domain() {
fn invalid_domain_string_is_rejected() {
let json = r#"{"id": "smelting", "name": "Smelting", "domain": "All"}"#;
let err = serde_json::from_str::<TechDefinition>(json)
.err()
.expect("'All' must be rejected");
assert!(err.to_string().contains("variant"));
}
#[test]
fn all_authored_techs_have_valid_domain() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
@ -425,16 +458,12 @@ mod tests {
});
for def in &defs {
total += 1;
if def.domain.is_empty() {
failures.push(format!("{}: domain is empty", def.id));
continue;
}
if !VALID_DOMAINS.contains(&def.domain.as_str()) {
failures.push(format!(
"{}: domain '{}' not in canonical 10-value enum",
def.id, def.domain
));
if def.domain.is_none() {
failures.push(format!("{}: domain is missing", def.id));
}
// If `domain` deserialised at all it is already a valid
// `TechDomain` variant (serde rejects unknown strings),
// so a separate enum-membership check is redundant.
}
}

View file

@ -322,6 +322,7 @@ fn def(id: &str, requires: &[&str], cost: u32) -> TechDefinition {
name: id.to_string(),
description: String::new(),
pillar: String::new(),
domain: None,
school: None,
era: 1,
tier: 1,