diff --git a/.project/objectives/p2-58a-tilestate-fauna-fields.md b/.project/objectives/p2-58a-tilestate-fauna-fields.md new file mode 100644 index 00000000..ddc113a6 --- /dev/null +++ b/.project/objectives/p2-58a-tilestate-fauna-fields.md @@ -0,0 +1,43 @@ +--- +id: p2-58a +title: "TileState fauna fields — fauna_density + fauna_index for AmbientTileCtx" +priority: p2 +status: done +scope: game1 +owner: game-systems +updated_at: 2026-05-07 +blocked_by: [] +evidence: + - src/simulator/crates/mc-core/src/grid/mod.rs — fauna_density + fauna_index added to TileState (p2-58a section, lines ~277-290) + - "cargo test -p mc-core tilestate_fauna: 3/3 pass (tilestate_fauna_fields_default, tilestate_fauna_roundtrip, tilestate_fauna_density_serde_default_on_missing_field)" + - "cargo check --workspace: clean (pre-existing solo_dominion errors only)" +--- +## Summary + +Adds `fauna_density: f32` and `fauna_index: Vec` to `TileState` in mc-core +so `AmbientTileCtx` (mc-ecology) can be populated from the live GameState in the +per-tile-moved encounter hook (p2-58b). + +`SpeciesId` is the existing string newtype from `mc-core::ids` (snake_case fauna species +identifier, e.g. `"grey_wolf"`). No new type was created. + +## Acceptance + +- [x] ✓ `TileState.fauna_density: f32` field added with `#[serde(default)]` +- [x] ✓ `TileState.fauna_index: Vec` field added with `#[serde(default)]` +- [x] ✓ `cargo test -p mc-core tilestate_fauna` passes — 3/3 tests green, including + backward-compat deserialization (old save files without these fields default cleanly) +- [x] ✓ `cargo check --workspace` clean of new errors (pre-existing solo_dominion + MapUnit/GameState field errors are pre-existing tech debt, not introduced here) + +## Population note + +`fauna_density` and `fauna_index` default to 0/empty. The ecology pipeline +(`mc-ecology::fauna_select::pick_fauna_for_tile`) must be wired to write these fields +back onto `TileState` after worldgen — tracked in p2-58b. Until then, encounter rolls +will always see density=0 and skip (correct behaviour for uninitialized maps). + +## Out of scope + +- Wiring the ecology pipeline to populate these fields (p2-58b) +- The actual `AmbientTileCtx` call site in `mc-turn::movement` (p2-58b) diff --git a/.project/objectives/p2-58b-ambient-encounter-hook.md b/.project/objectives/p2-58b-ambient-encounter-hook.md new file mode 100644 index 00000000..6a5d25c0 --- /dev/null +++ b/.project/objectives/p2-58b-ambient-encounter-hook.md @@ -0,0 +1,40 @@ +--- +id: p2-58b +title: "Ambient encounter hook — mc-turn::movement calls roll_ambient_encounter per tile step" +priority: p2 +status: stub +scope: game1 +owner: unassigned +updated_at: 2026-05-07 +blocked_by: [p2-58a] +--- +## Summary + +With `TileState.fauna_density` and `TileState.fauna_index` now populated (p2-58a), +the per-tile-moved hook in `mc-turn::movement` (or `processor.rs` movement phase) +can build `AmbientTileCtx` from the live `GameState` and call +`mc_ecology::encounter::roll_ambient_encounter(...)`. + +Also needed: the ecology pipeline must write `fauna_density` + `fauna_index` back +onto `TileState` after worldgen (currently `pick_fauna_for_tile` in +`mc-ecology::fauna_select` uses an ephemeral context; the result needs to persist +on the tile for `mc-turn` to consume at runtime). + +## Acceptance + +- [ ] `mc-ecology` finalise pass (post-worldgen) writes `fauna_density` and + `fauna_index` onto each `TileState` from `pick_fauna_for_tile` results. +- [ ] `mc-turn::movement` (or `processor.rs` movement phase) builds `AmbientTileCtx` + from `GameState::tiles[tile_idx].fauna_density` + `.fauna_index` per step. +- [ ] Calls `mc_ecology::encounter::roll_ambient_encounter(ctx, unit_kind, &mut rng)` + and appends any `Some(EncounterSpec)` to the turn's event log. +- [ ] `cargo test -p mc-turn test_ambient_encounter_fires` — seeded 50-step walk + through `fauna_density=0.8` tile yields ≥1 encounter. +- [ ] GUT integration test: scout in wilderness, assert `EventBus.encounter_started` + fires within 20 moves at density=0.8. + +## Out of scope + +- Pioneer escort rules (p2-59). +- Lair siege/assault/raid (p3-10). +- Encounter narrative text / event cards — separate UI objective. diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 8f79f740..c4cd7401 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -4,6 +4,7 @@ pub mod biome_registry; // works without drilling through the biome_registry module. pub use biome_registry::terrain_tier_cap; +use crate::ids::SpeciesId; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -274,6 +275,16 @@ pub struct TileState { /// BFS distance to nearest river/lake hex. 0 = on water; u8::MAX = beyond MAX_RIPARIAN_DISTANCE. #[serde(default = "default_riparian_distance")] pub riparian_distance: u8, + // ── Fauna ambient fields (p2-58a) ───────────────────────────────────────── + /// Aggregate ambient fauna density (0.0 = none, 1.0 = saturated). Populated + /// by the ecology pipeline after fauna placement; used by AmbientTileCtx in + /// the per-tile-moved encounter roll (p2-58b). + #[serde(default)] + pub fauna_density: f32, + /// Candidate species present on this tile (filtered by trophic + domain gates). + /// Populated by mc-ecology::fauna_select after worldgen; consumed by encounter roll. + #[serde(default)] + pub fauna_index: Vec, } impl Default for TileState { @@ -375,6 +386,8 @@ impl Default for TileState { stream_order: 1, lake_id: None, riparian_distance: u8::MAX, + fauna_density: 0.0, + fauna_index: Vec::new(), } } } @@ -1115,5 +1128,47 @@ mod tests { assert!(f.road, "road feature was wiped by migration"); assert!(!f.river, "road edge incorrectly marked as river"); } + + // ── p2-58a: TileState fauna fields ────────────────────────────────────── + + #[test] + fn tilestate_fauna_fields_default() { + let t = TileState::default(); + assert_eq!(t.fauna_density, 0.0); + assert!(t.fauna_index.is_empty()); + } + + #[test] + fn tilestate_fauna_roundtrip() { + let mut t = TileState::default(); + t.fauna_density = 0.75; + t.fauna_index = vec![SpeciesId::new("grey_wolf"), SpeciesId::new("dire_bear")]; + let json = serde_json::to_string(&t).unwrap(); + let t2: TileState = serde_json::from_str(&json).unwrap(); + assert_eq!(t2.fauna_density, 0.75); + assert_eq!(t2.fauna_index, vec![SpeciesId::new("grey_wolf"), SpeciesId::new("dire_bear")]); + } + + #[test] + fn tilestate_fauna_density_serde_default_on_missing_field() { + // Tiles serialized before p2-58a won't have fauna_density/fauna_index. + // Round-trip a default tile (which has the correct enum variants), strip + // the two new keys, and verify they come back as zero/empty. + let mut t = TileState::default(); + t.col = 3; + t.row = 5; + let full_json = serde_json::to_string(&t).unwrap(); + // Remove fauna_density and fauna_index from the serialized form to simulate + // an old save file that predates p2-58a. + let stripped: serde_json::Value = serde_json::from_str(&full_json).unwrap(); + let mut map = stripped.as_object().unwrap().clone(); + map.remove("fauna_density"); + map.remove("fauna_index"); + let old_json = serde_json::to_string(&map).unwrap(); + let t2: TileState = serde_json::from_str(&old_json).unwrap(); + assert_eq!(t2.fauna_density, 0.0, "missing fauna_density should default to 0.0"); + assert!(t2.fauna_index.is_empty(), "missing fauna_index should default to empty vec"); + assert_eq!(t2.col, 3); + } }