From c1aadc42e60c1341fe0e21722371cff4b92237eb Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 6 May 2026 22:39:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20ambient=20encounter=20event=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-replay/src/event.rs | 22 ++++++- src/simulator/crates/mc-turn/Cargo.toml | 1 + src/simulator/crates/mc-turn/src/processor.rs | 58 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-replay/src/event.rs b/src/simulator/crates/mc-replay/src/event.rs index 405a391a..aa2286db 100644 --- a/src/simulator/crates/mc-replay/src/event.rs +++ b/src/simulator/crates/mc-replay/src/event.rs @@ -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, } } } diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 5a1ebb82..e2f44025 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -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 } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 2b858e37..215a7ec3 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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, + /// 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, } 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