feat(@projects/@magic-civilization): add fauna fields to tile state

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-06 21:53:51 -07:00
parent 9dd3233769
commit a7ab78751d
3 changed files with 138 additions and 0 deletions

View file

@ -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<SpeciesId>` 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<SpeciesId>` 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)

View file

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

View file

@ -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<SpeciesId>,
}
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);
}
}