feat(@projects/@magic-civilization): ✨ add grudge ledger integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d9e287d6b6
commit
79bc4c7c92
2 changed files with 187 additions and 0 deletions
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue