From 79bc4c7c9272149a34922a2b82ecd1b38618583d Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 7 May 2026 00:07:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20grudge=20ledger=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 103 +++++++++++++++++++++++ src/simulator/crates/mc-city/src/city.rs | 84 ++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 9c53793c..4d0a0ed0 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -26,6 +26,7 @@ use mc_ecology::EcologyEngine; use mc_ecology::fauna_product::{fauna_product_supply, FaunaProduct}; use mc_ecology::population::PopulationSlot; use mc_ecology::species::Species; +use mc_ecology::{GrudgeKey, GrudgeLedger}; use mc_mapgen::MapGenerator; struct MagicCivPhysicsExtension; @@ -546,10 +547,16 @@ impl GdEcologyPhysics { // `turn_manager.gd`'s tick loop and the save lifecycle is a follow-up. /// Wrapper exposing `mc_ecology::EcologyEngine` to Godot. +/// +/// Also carries a `GrudgeLedger` — the ledger is a standalone struct in +/// `mc-ecology` not embedded in `EcologyEngine`, so we store it here as a +/// parallel field. Call `register_grudge` from the combat outcome hook and +/// `grudge_against` from the combat-preview/AI-targeting path. #[derive(GodotClass)] #[class(base=RefCounted)] pub struct GdFaunaEcology { inner: EcologyEngine, + grudge_ledger: GrudgeLedger, base: Base, } @@ -558,6 +565,7 @@ impl IRefCounted for GdFaunaEcology { fn init(base: Base) -> Self { Self { inner: EcologyEngine::new(), + grudge_ledger: GrudgeLedger::new(), base, } } @@ -660,6 +668,101 @@ impl GdFaunaEcology { fn population_slot_count(&self) -> i64 { self.inner.tile_populations.values().map(|v| v.len() as i64).sum() } + + // ── Grudge bridge (p1-58 ecology cognition) ───────────────────────── + + /// Register a grudge against `player_id` for the species at `(col, row)`. + /// + /// Called from the combat-outcome hook when a cognitively-eligible creature + /// survives an attack (`CombatOutcome::Survived`). The grudge expires after + /// `expires_at_turn` (caller computes `current_turn + intelligence × 10`). + /// + /// `player_id` fits in u8 (max 255 players). `species_id` must be the + /// numeric id returned by `register_species_from_json`. + #[func] + fn register_grudge( + &mut self, + col: i32, + row: i32, + species_id: i64, + player_id: i64, + expires_at_turn: i64, + ) { + if !(0..=u32::MAX as i64).contains(&species_id) { + return; + } + if !(0..=u8::MAX as i64).contains(&player_id) { + return; + } + let key = GrudgeKey { col, row, species_id: species_id as u32 }; + self.grudge_ledger.register(key, player_id as u8, expires_at_turn as u32); + } + + /// Returns `true` when the species at `(col, row)` currently holds a live + /// grudge against `player_id` at `current_turn`. + /// + /// Used by the combat-preview scene to show a grudge badge, and by AI + /// targeting to prioritise retaliation. + #[func] + fn grudge_against( + &self, + col: i32, + row: i32, + species_id: i64, + player_id: i64, + current_turn: i64, + ) -> bool { + if !(0..=u32::MAX as i64).contains(&species_id) { + return false; + } + if !(0..=u8::MAX as i64).contains(&player_id) { + return false; + } + let key = GrudgeKey { col, row, species_id: species_id as u32 }; + self.grudge_ledger.holds_grudge_against(key, player_id as u8, current_turn as u32) + } + + /// Advance the grudge ledger by one turn, purging expired entries. + /// Call once per turn from `TurnManager._on_turn_advanced`. + #[func] + fn tick_grudges(&mut self, current_turn: i64) { + self.grudge_ledger.tick(current_turn as u32); + } + + // ── Tile population query (p1-58 ecology cognition) ───────────────── + + /// Return an Array of Dictionaries describing all population slots on the + /// tile at `(col, row)`. + /// + /// Each dictionary has keys: + /// `species_id` (int) — numeric species id + /// `population` (float) — current population count + /// `species_name` (String) — resolved from `species_library` when available + /// + /// Returns an empty Array when the tile has no populations or is out of + /// range. Used by the tile-inspector scene to show current ecology. + #[func] + fn populations_on_tile(&self, col: i32, row: i32) -> Array { + let mut out = Array::new(); + let Some(slots) = self.inner.tile_populations.get(&(col, row)) else { + return out; + }; + for slot in slots { + let mut d = Dictionary::new(); + d.set("species_id", slot.species_id as i64); + d.set("population", slot.population as f64); + // Resolve name from species_library (keyed by string id in EcologyEngine). + // We search by numeric id since library is keyed by string id. + let name = self.inner.species_library + .values() + .find(|s| s.id == slot.species_id) + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + d.set("species_name", GString::from(name)); + out.push(&d); + } + out + } } // ── GdAtmosphericChemistry ────────────────────────────────────────────── diff --git a/src/simulator/crates/mc-city/src/city.rs b/src/simulator/crates/mc-city/src/city.rs index 683371df..e3824c67 100644 --- a/src/simulator/crates/mc-city/src/city.rs +++ b/src/simulator/crates/mc-city/src/city.rs @@ -420,6 +420,90 @@ impl City { crate::stacking::check_can_build(def, &self.buildings) } + /// Returns `Ok(())` when a national wonder can be queued, enforcing both + /// the stacking rules and the `requires_buildings_all_cities` cross-city + /// prerequisite. + /// + /// `buildings_in_all_cities` — the intersection of building ids present in + /// *every* city owned by the civilization. The caller (typically + /// `mc-turn::processor` or the GDExt bridge) is responsible for computing + /// this set across all `City` instances before calling. + /// + /// For non-national wonders the call is equivalent to `can_build`. + pub fn can_build_national_wonder( + &self, + def: &crate::building::BuildingDef, + buildings_in_all_cities: &[&str], + ) -> Result<(), NationalWonderError> { + // Stacking check first. + crate::stacking::check_can_build(def, &self.buildings) + .map_err(NationalWonderError::Stacking)?; + + // Cross-city prerequisite check. + if !def.is_national_wonder_buildable(buildings_in_all_cities) { + return Err(NationalWonderError::MissingCityPrerequisite { + wonder_id: def.id.to_string(), + required: def + .requires_buildings_all_cities + .iter() + .map(|b| b.to_string()) + .collect(), + }); + } + Ok(()) + } +} + +/// Errors returned by [`City::can_build_national_wonder`]. +#[derive(Debug, Clone, PartialEq)] +pub enum NationalWonderError { + /// Stacking rule violated (the same error surface as `City::can_build`). + Stacking(crate::StackingError), + /// At least one city in the civilization is missing a required building. + MissingCityPrerequisite { + wonder_id: String, + /// The building ids that must appear in every city. + required: Vec, + }, +} + +impl std::fmt::Display for NationalWonderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stacking(e) => write!(f, "stacking: {e}"), + Self::MissingCityPrerequisite { wonder_id, required } => write!( + f, + "national wonder {wonder_id} requires {:?} in every city", + required + ), + } + } +} + +impl std::error::Error for NationalWonderError {} + +/// Compute the set of building ids present in every city in `cities`. +/// +/// Used by callers of [`City::can_build_national_wonder`] to derive the +/// intersection argument. Returns an empty `Vec` when `cities` is empty +/// (blocking all national wonders — the correct behaviour for a civilization +/// with no cities). +pub fn buildings_common_to_all_cities<'a>(cities: &'a [&'a City]) -> Vec<&'a str> { + if cities.is_empty() { + return Vec::new(); + } + // Start with the first city's buildings, then intersect with each subsequent city. + let mut common: Vec<&str> = cities[0].buildings.iter().map(|b| b.as_str()).collect(); + for city in &cities[1..] { + let city_set: std::collections::HashSet<&str> = + city.buildings.iter().map(|b| b.as_str()).collect(); + common.retain(|b| city_set.contains(b)); + } + common +} + +impl City { + /// Completion-time hook: appends `def.id` to the city's building list and /// applies `consumes_existing` (removing the predecessor when set). ///