diff --git a/public/games/age-of-dwarves/docs/FAUNA_COMBAT_STATS.md b/public/games/age-of-dwarves/docs/FAUNA_COMBAT_STATS.md index a856b38e..dda3cb42 100644 --- a/public/games/age-of-dwarves/docs/FAUNA_COMBAT_STATS.md +++ b/public/games/age-of-dwarves/docs/FAUNA_COMBAT_STATS.md @@ -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 (1–10), 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`). diff --git a/src/simulator/crates/mc-city/src/building.rs b/src/simulator/crates/mc-city/src/building.rs index 0f107afc..663d5889 100644 --- a/src/simulator/crates/mc-city/src/building.rs +++ b/src/simulator/crates/mc-city/src/building.rs @@ -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": "", "value": }`. 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 { @@ -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, + pub specialist_slots: Vec, /// 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, + pub requires_buildings_all_cities: Vec, + + /// 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, /// 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] diff --git a/src/simulator/crates/mc-city/src/placement.rs b/src/simulator/crates/mc-city/src/placement.rs index b3a89597..73bcc9f2 100644 --- a/src/simulator/crates/mc-city/src/placement.rs +++ b/src/simulator/crates/mc-city/src/placement.rs @@ -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, } } diff --git a/src/simulator/crates/mc-core/src/gpp.rs b/src/simulator/crates/mc-core/src/gpp.rs new file mode 100644 index 00000000..7443d4f6 --- /dev/null +++ b/src/simulator/crates/mc-core/src/gpp.rs @@ -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_` 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_` 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); + } +} diff --git a/src/simulator/crates/mc-core/src/ids.rs b/src/simulator/crates/mc-core/src/ids.rs new file mode 100644 index 00000000..d660f618 --- /dev/null +++ b/src/simulator/crates/mc-core/src/ids.rs @@ -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) -> 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 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 = 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"); + } +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 1d93ac05..26bc9ff9 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -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; diff --git a/src/simulator/crates/mc-core/src/tech.rs b/src/simulator/crates/mc-core/src/tech.rs new file mode 100644 index 00000000..70a02be3 --- /dev/null +++ b/src/simulator/crates/mc-core/src/tech.rs @@ -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("ed) + .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::("\"All\""); + assert!(bad.is_err(), "'All' is a UI sentinel, must not deserialise"); + let bad = serde_json::from_str::("\"agriculture\""); + assert!(bad.is_err(), "lowercase variants must be rejected"); + } +} diff --git a/src/simulator/crates/mc-ecology/src/species.rs b/src/simulator/crates/mc-ecology/src/species.rs index 8b0288d8..071431f7 100644 --- a/src/simulator/crates/mc-ecology/src/species.rs +++ b/src/simulator/crates/mc-ecology/src/species.rs @@ -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{}", diff --git a/src/simulator/crates/mc-flora/src/generation.rs b/src/simulator/crates/mc-flora/src/generation.rs index b767cd2e..010e326f 100644 --- a/src/simulator/crates/mc-flora/src/generation.rs +++ b/src/simulator/crates/mc-flora/src/generation.rs @@ -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, + #[serde(default)] biomes: Vec, + #[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 = 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::(&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 &[ diff --git a/src/simulator/crates/mc-tech/src/web.rs b/src/simulator/crates/mc-tech/src/web.rs index 2ad77cf6..b939fbf7 100644 --- a/src/simulator/crates/mc-tech/src/web.rs +++ b/src/simulator/crates/mc-tech/src/web.rs @@ -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 { @@ -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, #[serde(default)] pub school: Option, #[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> { + let mut out: BTreeMap> = 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::(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. } } diff --git a/src/simulator/crates/mc-tech/tests/tech_web_tests.rs b/src/simulator/crates/mc-tech/tests/tech_web_tests.rs index 2bcc0738..a1923bf7 100644 --- a/src/simulator/crates/mc-tech/tests/tech_web_tests.rs +++ b/src/simulator/crates/mc-tech/tests/tech_web_tests.rs @@ -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,