feat(@projects/@magic-civilization): add grudge ledger integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 00:07:05 -07:00
parent d9e287d6b6
commit 79bc4c7c92
2 changed files with 187 additions and 0 deletions

View file

@ -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<RefCounted>,
}
@ -558,6 +565,7 @@ impl IRefCounted for GdFaunaEcology {
fn init(base: Base<RefCounted>) -> 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<Dictionary> {
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 ──────────────────────────────────────────────

View file

@ -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<String>,
},
}
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).
///