feat(@projects/@magic-civilization): add ambient encounter event handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 22:39:48 -07:00
parent 63565d1d31
commit c1aadc42e6
3 changed files with 80 additions and 1 deletions

View file

@ -10,6 +10,10 @@
use serde::{Deserialize, Serialize};
use crate::ids::{CityName, ClanId, EraId, LeaderId, TechId, TileCoord, UnitKind, WonderId};
/// Species identifier for ambient encounter events. Mirrors
/// `mc_core::ids::SpeciesId` without introducing a cross-crate dep in
/// mc-replay.
pub type SpeciesId = String;
/// A single chronicle event. Sorted by `turn` field at the end of each turn
/// when [`crate::history::TurnEventCollector::flush_to_history`] runs.
@ -119,6 +123,21 @@ pub enum TurnEvent {
/// Tech node now unlocked.
tech: TechId,
},
/// A unit triggered a Layer-1 ambient encounter on a wilderness tile
/// (p2-58b). The encounter fired from `fauna_density` on the tile, not
/// lair proximity.
AmbientEncounterFired {
/// Turn the event fired on.
turn: u32,
/// Clan whose unit triggered the encounter.
clan: ClanId,
/// Tile the unit stepped onto.
hex: TileCoord,
/// Species drawn from the tile's `fauna_index`.
species: SpeciesId,
/// Group size rolled for the encounter.
group_size: u8,
},
}
impl TurnEvent {
@ -137,7 +156,8 @@ impl TurnEvent {
| Self::EraEntered { turn, .. }
| Self::LeaderChanged { turn, .. }
| Self::ClanEliminated { turn, .. }
| Self::TechResearched { turn, .. } => turn,
| Self::TechResearched { turn, .. }
| Self::AmbientEncounterFired { turn, .. } => turn,
}
}
}

View file

@ -16,6 +16,7 @@ mc-economy = { path = "../mc-economy" }
mc-trade = { path = "../mc-trade" }
mc-tech = { path = "../mc-tech" }
mc-replay = { path = "../mc-replay" }
mc-ecology = { path = "../mc-ecology" }
serde.workspace = true
serde_json.workspace = true
wgpu = { version = "24", optional = true }

View file

@ -38,6 +38,7 @@ use mc_combat::{CombatBonuses, CombatParams, CombatResolver, CombatType, UnitSta
use mc_combat::CombatOutcome;
use mc_combat::{check_strategic_reqs, credit_resources, debit_resources};
use mc_economy::{process_gold, CityGoldInput, UnitMaintenanceInput};
use mc_ecology::encounter::{AmbientTileCtx, EncounterRates};
use mc_tech::{PlayerTechState, TechWeb};
use mc_trade::{
advance_relations, apply_trade_offer, declare_war, evaluate_trade_offer, evaluate_trades,
@ -232,6 +233,11 @@ pub struct TurnProcessor {
/// When set, enables multi-condition victory checks (economic, culture,
/// science, domination, city-count) instead of the simple city-count only.
pub victory_config: Option<crate::victory::VictoryConfig>,
/// Layer-1 ambient encounter rates loaded from
/// `public/resources/ecology/encounter_rates.json`. When `None` the
/// ambient hook no-ops silently (bench runs without ecology data).
#[serde(skip)]
pub encounter_rates: Option<EncounterRates>,
}
impl TurnProcessor {
@ -254,6 +260,7 @@ impl TurnProcessor {
palace_registry: BuildingRegistry::new(),
lair_combat_config: LairCombatConfig::default(),
victory_config: None,
encounter_rates: None,
}
}
@ -1516,6 +1523,57 @@ impl TurnProcessor {
}
}
// Step 1b: Layer-1 ambient encounter rolls (p2-58b).
// After movement, each unit on a wilderness tile with
// fauna_density > 0 rolls against the loaded EncounterRates.
// No-ops silently when encounter_rates is None (bench / unit
// tests without ecology data).
if let Some(ref rates) = self.encounter_rates {
if let Some(ref g) = state.grid {
let map_seed = g.seed;
for unit in &state.players[pi].units {
let uc = unit.col;
let ur = unit.row;
if uc < 0 || ur < 0 || uc >= g.width || ur >= g.height {
continue;
}
let tile_idx = (ur * g.width + uc) as usize;
let tile = &g.tiles[tile_idx];
if tile.fauna_density <= 0.0 || tile.fauna_index.is_empty() {
continue;
}
// Deterministic RNG per (map_seed, turn, player, unit).
let mut rng = {
let step_seed = mc_core::seed::derive_step(
map_seed,
mc_core::seed::SeedDomain::Encounter,
&[state.turn as u64, unit.id as u64, pi as u64],
);
mc_core::seed::Pcg64::seed(step_seed)
};
let ctx = AmbientTileCtx {
fauna_density: tile.fauna_density,
ecology_tier: tile.ecosystem_tier.max(0) as u8,
fauna_index: &tile.fauna_index,
};
if let Some(spec) = mc_ecology::encounter::roll_ambient_encounter(
&ctx,
&unit.kind,
rates,
&mut rng,
) {
result.events_emitted.push(mc_replay::TurnEvent::AmbientEncounterFired {
turn: state.turn,
clan: mc_replay::ClanId(pi as u32),
hex: mc_replay::ids::TileCoord::new(uc, ur),
species: spec.species_id.as_str().to_owned(),
group_size: spec.group_size,
});
}
}
}
}
// Step 2: resolve encounters. For each unit, the spatial index
// gives us the exact list of in-range lair indices — no per-lair
// distance check needed. We snapshot unit positions first so we