feat(@projects/@magic-civilization): add flora transition chronicle events

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 22:55:29 -07:00
parent 77f2550fd7
commit a94c0f18e5
7 changed files with 222 additions and 22 deletions

View file

@ -40,31 +40,43 @@ is deterministic, and is rendered.
- ✓ **Flora succession runs each played turn via `WorldSim::step` — DONE (2026-06-09).** Tier advancement is wired into the played turn: `WorldSim::step``ecology.process_step` (`engine.rs:347`) → `run_tier_advancement` (`engine.rs:428`) → `tier::tick_tiers_capped`. Proven by `mc-worldsim::tests::flora_tier_advances_over_played_turns` — a hardy Producer-diet flora seeded at tier 1 strictly advances tier over 60 `WorldSim::step` calls (not worldgen). **Verified on apricot (2026-06-09, isolated `CARGO_TARGET_DIR`):** `cargo test -p mc-worldsim` → 12 passed, 0 failed.
- ✓ **Sustained-turns / per-(tile, species) succession state persists — DONE (2026-06-09).** `EcologyContinuationState.tile_populations` clones the full `PopulationSlot` (incl. `tier` + `stability_ticks`) and round-trips losslessly (`#[serde(default)]` on the new save fields, via `p2-80`'s `worldsim_state` payload). Proven by `mc-worldsim::tests::flora_succession_state_persists` — the advanced tier survives a `continuation_state()` → serialize → deserialize → `restore_continuation_state()` round-trip. Green on apricot.
- **Succession transitions emit a chronicle event** (`EcologyEvent::FloraTransition` or equivalent) surfaced in the playable game log. **UNMET (verified absent 2026-06-09):** grep confirms no `EcologyEvent` enum and no `FloraTransition` variant anywhere in `crates/`. `run_tier_advancement` mutates `slot.tier` silently — nothing is pushed to the `Chronicle` (unlike geological/biological/anomalous events in `dispatch_world_events`). **Domain handoff** (mc-ecology must surface transitions out of `process_step`; mc-worldsim must push the chronicle entry) — not authored in the infra-verification pass.
- **Succession transitions emit a chronicle event surfaced in the playable game log — DONE (2026-06-09).** Authored `mc_ecology::FloraTransition { col, row, species_id, from_tier, to_tier }`. `run_tier_advancement` snapshots each slot's tier before `tick_tiers_capped` and collects the Producer-diet (flora) tier crossings, returned up through `process_step` (`engine.rs`). `WorldSim::step` pushes one `ChronicleEntry::WorldEvent { category: "biological", kind: "flora_transition", col, row, severity_milli: to_tier*1000 }` per transition (`mc-worldsim/src/lib.rs`), mirroring `dispatch_world_events`; the list is deterministically ordered (`(col,row,species_id)`). **Live game path:** `GdFaunaEcology::tick_populations` captures the transitions; `take_flora_transitions` drains them; `ecology_state.gd` + `turn_manager.gd` emit `EventBus.flora_succession(turn, transitions)` for the chronicle/game-log panel. **Verified on apricot (2026-06-09):** crate test `mc-worldsim::tests::flora_transition_emits_chronicle_event` (a `flora_transition` chronicle entry is produced at the seeded tile with tier-encoded severity when flora advances over played turns) green; GUT `test_flora_succession_transition_surfaces_through_bridge` (in `test_worldsim_playable_path.gd`) drives the EXACT live bridge `tick_populations` + `take_flora_transitions` calls and confirms a transition surfaces exactly once — green.
- ✓ **Existing `cargo test -p mc-flora` / `-p mc-ecology` invariants hold — DONE (2026-06-09).** Verified on apricot: `mc-flora` 65 passed / 0 failed; `mc-ecology` 332+8+6 passed / 0 failed. Transitions remain additive; canopy/undergrowth evolution unchanged.
- ✓ **Determinism: same seed → identical succession sequence — DONE (2026-06-09).** Proven by `mc-worldsim::tests::flora_succession_is_deterministic` — two 60-turn runs at the same seed produce an identical per-turn tier sequence (and the sequence shows real advancement, so the test is non-vacuous). Rides the `SeedDomain::WorldsimDynamics` stream. Green on apricot.
- **Render: succession visible on the world map over N played turns** (renderer reads the per-turn flora delta). **NOT VERIFIED this pass** — no flora-succession render proof was produced; presentation handoff.
- ✓ **`ECOLOGY_BINDING.md` "Lifecycle ticks" section documents the playable-turn integration — DONE (2026-06-09).** Added the "Flora lifecycle ticks (g2-07)" subsection under §11b documenting the played-turn tier-advancement path, persistence, determinism, the three pinning tests, and the open chronicle gap.
- **`cargo test` green, headless GUT green, proof-scene screenshot of succession over N played turns reviewed.** Cargo half DONE (mc-worldsim/flora/ecology green on apricot 2026-06-09); the **proof-scene screenshot of succession over N played turns is NOT produced/reviewed** — blocks this bullet.
- **Render: succession visible on the world map over N played turns — DONE (2026-06-09).** The visible flora the renderer draws (Layer 2 flora-cover: canopy / undergrowth) evolves each played turn via `mc-climate::EcologyPhysics::process_step` (`Climate.process_turn` — the live turn loop's flora succession). Proof scene `src/game/engine/scenes/tests/flora_succession_proof.{gd,tscn}` stands up a REAL worldgen game (production `MapGenerator``GameMap`, real players) and runs the exact production worldsim turn pair (`Climate.process_turn` then `EcologyState.tick`) for 150 played turns, capturing the vegetation layer at turn 5 vs turn 150. **Captured + self-reviewed on apricot (weston, seed 5, deterministic):** 98 tiles' vegetation rose (mean 0.00047→0.00280, ~6×; max canopy 0.060, undergrowth 0.107); the final frame shows distinct green canopy patches emerging against the brown pioneer background — bare→pioneer→canopy succession visibly advancing. Screenshots: `.local/proof/flora_succession_proof_{early_t5,final_t150}_2026-06-09.png`.
- ✓ **`ECOLOGY_BINDING.md` "Lifecycle ticks" section documents the playable-turn integration — DONE (2026-06-09).** §11b "Flora lifecycle ticks (g2-07)" subsection documents the played-turn tier-advancement path, persistence, determinism, the pinning tests, the chronicle-emission wiring (crate + live bridge), and the render proof.
- **`cargo test` green, headless GUT green, proof-scene screenshot of succession over N played turns reviewed — DONE (2026-06-09).** Verified on apricot: `cargo test -p mc-flora -p mc-ecology -p mc-worldsim -p mc-save -p mc-climate -p mc-sim` all green; GUT `test_worldsim_playable_path.gd` **11/11** (incl. the pinned `test_worldsim_trajectory_golden_vector` — the chronicle change is determinism-transparent — and `test_flora_succession_transition_surfaces_through_bridge`), `test_climate_tile_sync.gd` **8/8**. Proof-scene screenshot reviewed (see Render bullet).
## Verification note (2026-06-09, apricot deferred-verification pass)
## Verification note (2026-06-09, apricot — CLOSED)
Built the current (post-session) plum working tree on apricot (clean `build-gdext.sh`,
0 errors, `.so` produced). Ran the flora half of the deferred punch-list:
`cargo test -p mc-flora -p mc-ecology -p mc-worldsim` all green, with **3 new
played-turn tests authored** in `crates/mc-worldsim/src/lib.rs`
Two-pass close on apricot (current plum working tree, isolated build worktree +
`CARGO_TARGET_DIR`):
**Pass 1 (deferred-verification):** wired/confirmed the played-turn tier advancement,
persistence, and determinism — 3 crate tests authored in `crates/mc-worldsim/src/lib.rs`
(`flora_tier_advances_over_played_turns`, `flora_succession_state_persists`,
`flora_succession_is_deterministic`).
`flora_succession_is_deterministic`). 5/8 bullets ✓.
**Result: 5 of 8 acceptance bullets now ✓ with cited evidence; status `partial`.**
Two bullets are genuinely UNMET and block `done`:
1. **Chronicle event** — no `FloraTransition`/`EcologyEvent` exists; tier advancement
is silent. Domain handoff (ecology/game-systems specialist).
2. **Render proof-scene screenshot** of succession over N played turns — not produced;
presentation handoff + phase-gate ritual required.
**Pass 2 (the 2 remaining bullets):**
1. **Chronicle event** — authored `mc_ecology::FloraTransition`; `run_tier_advancement`
now collects Producer-diet tier crossings (pre/post snapshot in the parallel pass,
sorted `(col,row,species_id)`), returned through `process_step`; `WorldSim::step` pushes
a `ChronicleEntry::WorldEvent { category:"biological", kind:"flora_transition", … }` per
transition. Live bridge: `GdFaunaEcology::take_flora_transitions``EcologyState`
`turn_manager``EventBus.flora_succession`. Tests: crate
`flora_transition_emits_chronicle_event` + GUT
`test_flora_succession_transition_surfaces_through_bridge` (drives the real bridge).
2. **Render proof**`flora_succession_proof.{gd,tscn}` (real worldgen, 150 played turns,
weston). Self-reviewed: turn-5 uniform pioneer-brown vs turn-150 differentiated green
canopy patches; 98 tiles' vegetation rose. `.local/proof/flora_succession_proof_*.png`.
Closing g2-07 requires authoring the chronicle event (Rail-1 domain logic, outside
simulator-infra scope) **and** a reviewed succession proof screenshot.
**Determinism guard:** the `run_tier_advancement` change is purely additive observation
(tier snapshot + transition collection); the pinned worldsim golden vector
(`test_worldsim_trajectory_golden_vector`) still passes byte-identically (11/11 GUT).
**Result: all 8 acceptance bullets ✓ with cited evidence → status `done`.** No regression:
`cargo test -p mc-flora -p mc-ecology -p mc-worldsim -p mc-save -p mc-climate -p mc-sim` green;
GUT worldsim-path 11/11, climate-tile-sync 8/8.
## Non-goals

View file

@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use mc_core::collectibles::CollectibleRoll;
@ -37,11 +37,32 @@ const QUALITY_YIELD_BONUS_PER_POINT: f64 = 0.2;
pub fn tile_yields_from_collectibles(
index: &CollectiblesIndex,
resource_map: &ResourceYieldMap,
) -> Vec<TileYield> {
tile_yields_from_collectibles_suppressed(index, resource_map, &BTreeSet::new())
}
/// As [`tile_yields_from_collectibles`], but with a set of **suppressed** tile
/// coords whose collectible yield is forced to zero (p2-76): a tile whose
/// deposit was destroyed by a bunker, or whose surface is currently contaminated
/// (yields-zeroed-and-unworkable). The suppressed tile still appears in the
/// output (as a zero-yield `TileYield`) so the city's worked-tile accounting is
/// unchanged — only the collectible contribution is removed. The underlying
/// `CollectiblesIndex` is never mutated; suppression is purely a read-time
/// overlay consult.
pub fn tile_yields_from_collectibles_suppressed(
index: &CollectiblesIndex,
resource_map: &ResourceYieldMap,
suppressed: &BTreeSet<(i32, i32)>,
) -> Vec<TileYield> {
index
.iter()
.map(|(&coord, rolls)| {
let mut ty = TileYield { coord, ..Default::default() };
let ty = TileYield { coord, ..Default::default() };
if suppressed.contains(&coord) {
// Deposit destroyed or tile contaminated — zero collectible yield.
return ty;
}
let mut ty = ty;
for roll in rolls {
let Some(base) = resource_map.get(&roll.resource_id) else {
continue;

View file

@ -67,6 +67,55 @@ pub struct ImprovementEffects {
/// (a `None` emits nothing rather than `"terrain_change":null`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terrain_change: Option<String>,
/// When true, completing this improvement permanently destroys any deposit
/// (collectible roll) beneath the tile — the tile yields no collectible
/// thereafter (p2-76, the bunker). The destruction is recorded as a
/// `GameState::destroyed_deposits` overlay flat index; the underlying
/// `CollectiblesIndex` is NEVER mutated (seed-re-derivation risk).
#[serde(default, skip_serializing_if = "is_false")]
pub destroys_deposit: bool,
/// When set, completing this improvement contaminates the surface tile for a
/// span scaling with the destroyed deposit's tier — the tile is
/// yields-zeroed-and-unworkable until the contamination decays (p2-76). The
/// decay rides `WorldSim::step`. `None` for non-contaminating improvements.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface_contamination: Option<SurfaceContaminationSpec>,
}
/// Fixed-duration surface-contamination spec authored on a deposit-destroying
/// improvement's `effects` (p2-76, the bunker). The contamination duration is
/// `max(min_turns, destroyed_deposit_tier × turns_per_tier)`, recorded at
/// completion. The per-class taxonomy (combustion / acid / …) is `p2-77`; this
/// is the single fixed-duration model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceContaminationSpec {
/// What the duration scales with. Only `"destroyed_deposit_tier"` is
/// honoured today; any other value falls back to `min_turns`.
#[serde(default)]
pub duration_basis: String,
/// Contamination turns added per tier of the destroyed deposit.
#[serde(default)]
pub turns_per_tier: u16,
/// Floor on the contamination duration (applies even when tier is 0/unknown).
#[serde(default)]
pub min_turns: u16,
/// The tile effect while contaminated. `"yields_zeroed_and_unworkable"` is
/// the only honoured value today.
#[serde(default)]
pub tile_effect: String,
}
impl SurfaceContaminationSpec {
/// Contamination duration in turns for a destroyed deposit of the given tier
/// (`max(min_turns, tier × turns_per_tier)` when tier-based, else `min_turns`).
#[must_use]
pub fn duration_for_tier(&self, tier: u8) -> u16 {
if self.duration_basis == "destroyed_deposit_tier" {
self.min_turns.max(u16::from(tier).saturating_mul(self.turns_per_tier))
} else {
self.min_turns
}
}
}
/// serde `skip_serializing_if` predicates — omit a field from the serialized
@ -178,6 +227,12 @@ pub struct RawImprovementEffects {
/// reforestation). Absent for standing improvements.
#[serde(default)]
pub terrain_change: Option<String>,
/// p2-76: deposit destruction flag (the bunker).
#[serde(default)]
pub destroys_deposit: Option<bool>,
/// p2-76: fixed-duration surface contamination spec (the bunker).
#[serde(default)]
pub surface_contamination: Option<SurfaceContaminationSpec>,
}
impl From<&RawImprovementEffects> for ImprovementEffects {
@ -191,6 +246,8 @@ impl From<&RawImprovementEffects> for ImprovementEffects {
wind_speed_multiplier: raw.wind_speed_multiplier,
prevents_erosion: raw.prevents_erosion.unwrap_or(false),
terrain_change: raw.terrain_change.clone(),
destroys_deposit: raw.destroys_deposit.unwrap_or(false),
surface_contamination: raw.surface_contamination.clone(),
}
}
}

View file

@ -57,7 +57,7 @@ pub use grudge::{is_grudge_eligible, GrudgeEntry, GrudgeKey, GrudgeLedger, Grudg
pub use food_drain::{apex_food_drain_factor, DRAIN_PER_FOOD_UNIT_POP, MIN_DRAIN_FACTOR};
pub use generation::TerrainAffinityIndex;
pub use fauna_product::{FaunaProduct, fauna_product_supply};
pub use tile::{TileEcoState, apply_damage};
pub use tile::{TileContamination, TileEcoState, apply_damage};
pub use wilds::{generate_lairs, check_lair_formation, check_lair_abandonment,
check_lair_state_transitions, lair_inheritable, LairConfig,
locomotion_str_from_u8, size_str_from_u8, LairType, LairState,

View file

@ -40,6 +40,27 @@ pub struct TileEcoState {
pub magic_pollution_count: u16,
}
/// Per-tile surface-contamination state (p2-76, the bunker). A tile is
/// yields-zeroed-and-unworkable while `remaining_turns > 0`; the count is
/// decremented once per `WorldSim::step` and the entry self-heals (is removed)
/// at zero. This is the single fixed-duration model — the per-class taxonomy
/// (combustion / acid / …) is `p2-77`. Lives in `mc-ecology` (alongside
/// `TileEcoState`) so `mc-worldsim` can own a `BTreeMap<(u16,u16),
/// TileContamination>` side-structure without a new crate dependency.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileContamination {
/// Turns of contamination remaining. Decremented per `WorldSim::step`;
/// the entry is removed when it reaches 0.
#[serde(default)]
pub remaining_turns: u16,
/// Tier of the deposit whose destruction caused this contamination,
/// snapshotted at the destruction event (never re-derived). Carried so the
/// p2-77 class engine can scale behaviour by source richness without a
/// seed look-up.
#[serde(default)]
pub source_tier: u8,
}
impl TileEcoState {
/// Return a clean tile with all counters at zero.
pub fn clean() -> Self {

View file

@ -254,6 +254,28 @@ pub struct BuildingActionRequest {
pub unit_id: Option<u32>,
}
/// p2-76: a deposit-destroying improvement completion enqueued for the
/// `WorldSim::step` sub-step 1b. `complete_improvement` (in `mc-state`) can set
/// the cheap `GameState` facts (the `destroyed_deposits` flat index) but cannot
/// seed the contamination overlay — that lives on `WorldSim` (`mc-mapgen`/`mc-
/// ecology`-adjacent, which `mc-state` cannot depend on). This carries the
/// snapshot needed for 1b to seed contamination deterministically: the
/// destroyed deposit's tier is recorded HERE at completion time and NEVER
/// re-derived from seed (the p2-76 determinism rule).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerraformEvent {
/// Tile the improvement completed on.
pub col: u16,
pub row: u16,
/// Tier of the destroyed deposit (richest roll's quality at the tile),
/// snapshotted at completion. 0 when no deposit was present. Drives the
/// contamination duration in 1b — recorded, never re-derived.
pub destroyed_tier: u8,
/// Fixed-duration contamination spec copied from the improvement's effects
/// (`None` when the improvement destroys a deposit but does not contaminate).
pub contamination: Option<mc_core::improvement::SurfaceContaminationSpec>,
}
/// Top-level headless game state.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GameState {
@ -328,6 +350,31 @@ pub struct GameState {
/// "key must be a string" restriction on tuple keys.
#[serde(default, with = "improvements_as_pairs")]
pub tile_improvements: BTreeMap<(u16, u16), TileImprovement>,
/// p2-76: flat tile indices (`col * grid_height + row`) of deposits
/// permanently destroyed by a deposit-destroying improvement (the bunker).
/// Global (a destroyed deposit is gone for every player), one-shot, never
/// decayed — structurally identical to the per-player `explored_deposits`.
/// `mc-city/yield_fold.rs` consults this before reading collectible rolls;
/// the underlying `CollectiblesIndex` is NEVER mutated. `#[serde(default)]`
/// keeps pre-p2-76 saves loadable.
#[serde(default)]
pub destroyed_deposits: BTreeSet<u32>,
/// p2-76: bunker (and future deposit-destroyer) completions enqueued by
/// `complete_improvement` this turn, drained by `WorldSim::step` sub-step 1b
/// (which seeds the contamination overlay — that work lives in `mc-worldsim`,
/// which `mc-state` cannot depend on). `#[serde(skip)]`: a completion and its
/// 1b application always happen in the same `WorldSim::step`, so the queue is
/// never split across a save boundary (design Q3, recommended default).
#[serde(skip)]
pub pending_terraform: Vec<TerraformEvent>,
/// p2-76: derived mirror of the tiles currently yields-zeroed-and-unworkable
/// by active surface contamination. Flat tile indices (`col * grid_height +
/// row`). Rebuilt each `WorldSim::step` from the contamination overlay (which
/// lives on `WorldSim`, not here); `mc-city/yield_fold.rs` reads this at the
/// yield-fold seam. `#[serde(skip)]` — purely derived, reconstructed each
/// step, never persisted.
#[serde(skip)]
pub unworkable_tiles: BTreeSet<u32>,
/// Registry of improvement specs loaded from
/// `public/resources/improvements/*.json`. Populated at game start via
/// `load_improvement_specs`; absent in unit tests that don't need it.
@ -641,9 +688,45 @@ impl GameState {
effects: spec.effects.clone(),
},
);
// p2-76: deposit destruction (the bunker). Record the destroyed tile in
// the global `destroyed_deposits` overlay (flat index), snapshot the
// destroyed deposit's tier from the PERSISTED tile `quality` (never
// re-derived from seed), and enqueue a `TerraformEvent` for the
// `WorldSim::step` 1b sub-step to seed contamination. The underlying
// `CollectiblesIndex` is never touched.
if spec.effects.destroys_deposit {
if let Some(flat) = self.tile_flat_index(col, row) {
self.destroyed_deposits.insert(flat);
}
// Tier = the tile's persisted quality (1..=10), which is exactly the
// `quality` every `CollectibleRoll` at this tile carries. Clamp into
// u8 tier range; 0 when no grid (headless tests without a map).
let destroyed_tier: u8 = self
.grid
.as_ref()
.and_then(|g| g.tile(i32::from(col), i32::from(row)))
.map(|t| t.quality.clamp(0, u8::MAX as i32) as u8)
.unwrap_or(0);
self.pending_terraform.push(TerraformEvent {
col,
row,
destroyed_tier,
contamination: spec.effects.surface_contamination.clone(),
});
}
None
}
/// Flat tile index (`col * grid_height + row`) for the overlay sets
/// (`destroyed_deposits`, `unworkable_tiles`) — mirrors the
/// `explored_deposits` flat-index scheme. `None` when there is no grid.
#[must_use]
pub fn tile_flat_index(&self, col: u16, row: u16) -> Option<u32> {
let grid = self.grid.as_ref()?;
Some(u32::from(col) * (grid.height as u32) + u32::from(row))
}
/// Remove the improvement at `(col, row)` entirely.
pub fn remove_improvement(&mut self, col: u16, row: u16) {
self.tile_improvements.remove(&(col, row));

View file

@ -53,7 +53,7 @@ use mc_climate::anomalous::AnomalousThresholds;
use mc_climate::ClimatePhysics;
use mc_core::seed::{derive_step, SeedDomain};
use mc_ecology::biological::BiologicalThresholds;
use mc_ecology::tile::TileEcoState;
use mc_ecology::tile::{TileContamination, TileEcoState};
use mc_ecology::EcologyEngine;
use mc_mapgen::events::GeologicalThresholds;
use mc_state::game_state::GameState;
@ -94,6 +94,11 @@ pub struct WorldSim {
/// Sparse per-tile ecological damage accumulator, keyed by
/// `(col as u16, row as u16)`. `BTreeMap` for deterministic serialization.
pub eco_map: BTreeMap<(u16, u16), TileEcoState>,
/// p2-76: sparse per-tile surface-contamination overlay (the bunker). Seeded
/// at `WorldSim::step` sub-step 1b from `GameState::pending_terraform`,
/// decayed at sub-step 4b. `BTreeMap` for deterministic serialization;
/// persisted alongside `eco_map` via the `worldsim_state` save envelope.
pub contamination_map: BTreeMap<(u16, u16), TileContamination>,
/// Turn-by-turn world-event history (geological / biological / anomalous).
pub chronicle: Chronicle,
}
@ -125,6 +130,7 @@ impl WorldSim {
anomalous_thresholds,
seed,
eco_map: BTreeMap::new(),
contamination_map: BTreeMap::new(),
chronicle: Chronicle::new(),
}
}