diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 4d0a0ed0..7592f3c6 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -674,8 +674,11 @@ impl GdFaunaEcology { /// 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`). + /// survives an attack (`CombatOutcome::Survived`). The caller computes + /// `expires_at_turn = current_turn + cognitive_profile.grudge_memory_turns` + /// (i.e. `intelligence × 10`) before calling this method. The bridge + /// takes an explicit `expires_at_turn` rather than a `&Species` so the + /// API stays stable without a serde round-trip through the species JSON. /// /// `player_id` fits in u8 (max 255 players). `species_id` must be the /// numeric id returned by `register_species_from_json`. @@ -686,6 +689,7 @@ impl GdFaunaEcology { row: i32, species_id: i64, player_id: i64, + current_turn: i64, expires_at_turn: i64, ) { if !(0..=u32::MAX as i64).contains(&species_id) { @@ -694,8 +698,21 @@ impl GdFaunaEcology { if !(0..=u8::MAX as i64).contains(&player_id) { return; } + // Look up the species from the library so `register` can gate eligibility. + // If the species is not in the library (e.g. seeded from external JSON + // without being registered in `species_library`), fall back to a raw + // direct entry that bypasses the eligibility gate — the caller is + // responsible for ensuring the creature is grudge-eligible in that case. 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); + let species_opt = self.inner.species_library + .values() + .find(|s| s.id == species_id as u32); + if let Some(species) = species_opt { + self.grudge_ledger.register(key, species, player_id as u8, current_turn as u32); + } else { + // Fallback: direct entry with caller-supplied expiry. + self.grudge_ledger.register_raw(key, player_id as u8, current_turn as u32, expires_at_turn as u32); + } } /// Returns `true` when the species at `(col, row)` currently holds a live diff --git a/src/simulator/crates/mc-ecology/src/grudge.rs b/src/simulator/crates/mc-ecology/src/grudge.rs index a9d2927f..71a560cb 100644 --- a/src/simulator/crates/mc-ecology/src/grudge.rs +++ b/src/simulator/crates/mc-ecology/src/grudge.rs @@ -121,6 +121,33 @@ impl GrudgeLedger { true } + /// Register a grudge with an explicit expiry turn, bypassing the + /// species-eligibility gate. Used by the GDExtension bridge when the + /// species object is not available at call time — the caller is + /// responsible for ensuring the creature is grudge-eligible. + /// + /// Renews an existing grudge entry for the same `(key, player_id)` pair + /// rather than appending a duplicate. + pub fn register_raw( + &mut self, + key: GrudgeKey, + player_id: GrudgePlayerId, + current_turn: u32, + expires_at_turn: u32, + ) { + let entries = self.grudges.entry(key).or_default(); + if let Some(existing) = entries.iter_mut().find(|e| e.player_id == player_id) { + existing.turn_recorded = current_turn; + existing.expires_turn = expires_at_turn; + } else { + entries.push(GrudgeEntry { + player_id, + turn_recorded: current_turn, + expires_turn: expires_at_turn, + }); + } + } + /// True when the species at `(col, row)` currently holds a live grudge /// against `player_id`. A grudge is "live" when `current_turn < expires_turn`. pub fn holds_grudge_against(