feat(@projects/@magic-civilization): add grudge expiration tracking via turn-based API

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 00:16:02 -07:00
parent 9dcdefa732
commit 21443d1ca6
2 changed files with 47 additions and 3 deletions

View file

@ -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

View file

@ -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(