From 97fde477c2fad1ea26013ddab6dbc112ec32c7f7 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 20:51:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20integrate=20flora=20lifecycle=20into=20played=20tur?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../g2-07-flora-lifecycle-transitions.md | 39 +++-- .../p2-80-mc-worldsim-integration.md | 18 +++ .../age-of-dwarves/docs/ECOLOGY_BINDING.md | 23 +++ .../test_capture_raze_view_reresolution.gd | 118 +++++++++++++++ src/simulator/crates/mc-worldsim/src/lib.rs | 137 ++++++++++++++++++ 5 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 src/game/engine/tests/integration/test_capture_raze_view_reresolution.gd diff --git a/.project/objectives/g2-07-flora-lifecycle-transitions.md b/.project/objectives/g2-07-flora-lifecycle-transitions.md index a1da287c..32acbf1f 100644 --- a/.project/objectives/g2-07-flora-lifecycle-transitions.md +++ b/.project/objectives/g2-07-flora-lifecycle-transitions.md @@ -2,9 +2,9 @@ id: g2-07 title: Flora succession — wire the existing flora lifecycle engine into the playable turn priority: p1 -status: missing +status: partial scope: game1 -updated_at: 2026-06-06 +updated_at: 2026-06-09 blocked_by: [p2-80] --- @@ -38,14 +38,33 @@ is deterministic, and is rendered. ## Acceptance -- ◻ Flora succession runs each **played** turn via `WorldSim::step` (`p2-80`) — confirm `tick_tiers` / tier-advancement mutates flora tiers over played turns, not just at worldgen. -- ◻ Sustained-turns / per-(tile, species) succession state persists through `mc-save` (round-trip test); `#[serde(default)]`. -- ◻ Succession transitions emit a chronicle event (`EcologyEvent::FloraTransition` or the existing equivalent) surfaced in the playable game log. -- ◻ Existing `cargo test -p mc-flora` / `-p mc-ecology` invariants hold (transitions remain additive; canopy/undergrowth values evolve as before). -- ◻ Determinism: same `(seed, save)` → identical succession sequence across played turns (PCG64 + `SeedDomain::WorldsimDynamics`); golden vector. -- ◻ Render: succession is visible on the world map over N played turns (the renderer reads the per-turn flora delta). -- ◻ `ECOLOGY_BINDING.md` "Lifecycle ticks" section documents the playable-turn integration. -- ◻ `cargo test` green, headless GUT green, proof-scene screenshot of succession over N played turns reviewed. +- ✓ **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. +- ✓ **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. + +## Verification note (2026-06-09, apricot deferred-verification pass) + +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` +(`flora_tier_advances_over_played_turns`, `flora_succession_state_persists`, +`flora_succession_is_deterministic`). + +**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. + +Closing g2-07 requires authoring the chronicle event (Rail-1 domain logic, outside +simulator-infra scope) **and** a reviewed succession proof screenshot. ## Non-goals diff --git a/.project/objectives/p2-80-mc-worldsim-integration.md b/.project/objectives/p2-80-mc-worldsim-integration.md index fbe7ab13..47a5e7e3 100644 --- a/.project/objectives/p2-80-mc-worldsim-integration.md +++ b/.project/objectives/p2-80-mc-worldsim-integration.md @@ -102,6 +102,24 @@ are pre-existing and causally independent of worldsim (see the cargo+GUT bullet) - ✓ **Render hook** — *the living world is visible, fog-gated, in real play.* Climate fields + flora succession + biome reclass were already drawn each turn by `hex_renderer.gd` (Layer 2 flora cover + Layer 4 biome sprite, refreshed via the per-turn fog/observation `queue_redraw`). **Fauna population** is now surfaced by `fauna_overlay_renderer.gd` (the `lair_overlay_renderer` pattern): a `wildlife_habitat`-lens overlay reading bulk `GdFaunaEcology::populated_tile_densities()` (→ `EcologyState.tile_densities()`), refreshed on `EventBus.worldsim_updated` (emitted by `turn_manager` after the ecology tick), and **fog-gated** so it never leaks fauna on unexplored tiles. **Joined real-game fog-gated proof (captured 2026-06-07 on apricot, `fauna_overlay_proof.gd` rebuilt to drive production systems, not a synthetic grid):** the proof stands up a REAL game — production `MapGenerator` → real `GameMap` registered as `GameState`'s primary layer, real players — then runs the EXACT production turn pair for 25 turns (`Climate.process_turn(game_map, t, seed)` building/syncing the Rust `GdGridState`, then `EcologyState.tick(climate._grid, …)` — the same two calls `turn_manager` makes), with **fog ON** (asserts `FORCE_DISABLE_FOGOFWAR == false` at runtime) and a partial radius reveal around the human founder, then drives the production `FaunaOverlayRenderer` via the real `wildlife_habitat` lens + `worldsim_updated` signals and screenshots. Runtime metrics (seed 5, duel, deterministic): **960 worldgen tiles · fog flag false · `view_player_index` = 0 (human-prefer branch) · 346 populated tiles · 561/960 revealed · `fog_gate_pass` = 144** — i.e. of 346 live-populated tiles, only the 144 inside the explored region are drawn and **202 are correctly hidden by fog**. The reviewed screenshot shows the three-way contrast: black unexplored frontier / lit explored territory / green→yellow fauna heatmap confined to explored tiles (`.local/proof/fauna_overlay_proof_real_fogged_2026-06-07.png`). This exercises the `_draw()` fog branch end-to-end on the real distribution — the gap the earlier separate harnesses left open. **The barren-world bug behind this is fixed:** the live engine never seeded fauna (`EcologyInitializer` was dead code) and emergence is a deliberate slow trickle (`emergence_rate_base` = 0.001) gated on flora-derived `habitat_suitability` (≈0 at genesis) — it cannot cold-start an empty map. Added world-genesis **seeding**: `emergence::seed_base_trophic` (reuses the emergence pickers/generators, bypasses the rarity roll, base trophic only — herbivore+detritivore on land / filter-feeder in water, gated on stable `quality` not flora-derived suitability) → `EcologyEngine::seed_initial` → `GdFaunaEcology::seed_initial_populations`, wired into `EcologyState.tick` (lazy, first-tick, skips already-populated tiles so loads aren't double-seeded). Also populated the emergence `species_library` (`load_species_library_from_json`) so ongoing emergence works. **Verified end-to-end on a REAL worldgen autoplay (seed 5, 25 turns):** seeding bootstrapped **960 populated tiles at genesis**, which the LV dynamics regulated to a stable **~376 tiles** (`fauna_tiles` in `turn_stats.jsonl`, instrumented in `auto_play.gd`) — a living, self-regulating world, no synthetic crutch. Render path also unit-proven by GUT `test_fauna_overlay.gd` 5/5 + seeding by `test_fauna_emergence_live.gd` (192 tiles, survives 40 turns); the real-game fog-gated screenshot above is the integrated artifact. - ✓ `cargo test` green (incl. save round-trip + determinism), headless GUT (worldsim path) green, proof-scene screenshot reviewed. *(Scoped to worldsim-path green + N pre-existing unrelated failures tracked separately — a carve-out the operator authorized explicitly for this bullet. **Verified on apricot 2026-06-09:** `cargo test -p mc-worldsim -p mc-save -p mc-ecology` green — mc-ecology 332+8+6, mc-save 5+7 (incl. `worldsim_state` round-trip + `#[serde(default)]` old-save compat), mc-worldsim 9 (incl. byte-identical determinism + save/load-transparency), 0 failed; api-gdext `load_items` 5/5. Worldsim playable-path GUT **10/10** (101 asserts) — the 3 accumulator tests, the pinned `test_worldsim_trajectory_golden_vector` determinism golden, and `test_continued_trajectory_byte_identical_after_save_load`; climate-tile-sync GUT 8/8 (incl. the 2 wind round-trip tests); fauna-overlay GUT 5/5. **Proof-scene screenshot reviewed:** `.local/proof/fauna_overlay_proof_real_fogged_2026-06-07.png` (real-game fog-gated living world over 25 played turns — see Render hook). **Full-suite headless GUT: 744 tests, 689 pass, 43 fail** (down from 56 — the `city_slot::load_items` "missing field `production_cost`" cascade was root-caused as a PRE-EXISTING serde gap, commit `5384278ca` predates session-start `a22dbc270` by 9h per `merge-base`, surfaced by the gdext rebuild; fixed via the `hammer_cost` alias + lenient-u32 coercion, now 0 `production_cost` errors). The residual 43 are pre-existing, causally-independent of worldsim/climate/ecology/save-accumulator paths: the `traded_luxuries`/`trade_ledger_json` diplomacy cluster (≈29), the `p2-71c` units-catalog turn-processor regression, AI-bridge tests (possibly swept in from a concurrent worker's `mc-player-api/learned` changes — flagged for separate attention), and fog/sprite/minimap/audio rendering tests. None touches any file changed for p2-80; every worldsim-path test is green. Tracked separately.)* +## Verification note (2026-06-09, apricot deferred-verification re-confirm) + +Re-confirmed against the **current post-session plum working tree** (built fresh on +apricot in an isolated worktree + `CARGO_TARGET_DIR`; `build-gdext.sh` clean, 0 errors, +41 MB `.so` produced). All worldsim-path evidence re-verified green on the latest tree: +- `cargo test -p mc-worldsim` → **12 passed, 0 failed** (incl. byte-identical determinism, + save/load-transparency, and 3 newly-added g2-07 flora-succession played-turn tests); + `mc-save` round-trip (`worldsim_state_round_trips_byte_equal` + old-save `#[serde(default)]`) + and `mc-ecology` (332+8+6) green; `mc-climate` 45 + `tile_sync_fields` 4 green. +- api-gdext `city_slot::load_items_tests` **5/5** (the `hammer_cost` alias / lenient-u32 + regression guard) + a new `save_round_trip_tests` city byte-identity test green. +- GUT (scoped, isolated via `-gconfig= -gtest=`): `test_worldsim_playable_path.gd` **10/10** + (101 asserts — accumulators, golden-vector determinism, continued-trajectory byte-identity); + `test_climate_tile_sync.gd` **8/8** (incl. both `wind_direction` round-trip tests). + +No regression found in any worldsim/climate/ecology/save path on the current tree. p2-80 +stays **done**. + ## Non-goals - Authoring any new simulation engine — they exist. This is integration, diff --git a/public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md b/public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md index a053fa66..b4dea93c 100644 --- a/public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md +++ b/public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md @@ -398,6 +398,29 @@ runs, in fixed order each turn: driver is a deferred extension (`g2-10` bullet 4).* 5. **Tier advancement, fish stocks, mangrove feedback.** +**Flora lifecycle ticks (g2-07).** Step 5's tier advancement is the flora-succession +driver in the **played** turn (not only worldgen). `EcologyEngine::run_tier_advancement` +(`engine.rs:428`, called from `process_step` at `engine.rs:347`) runs `tier::tick_tiers_capped` +on every tile's population slots each turn: a stable Producer-diet slot (population above +the crash threshold, habitat suitable) accrues one `stability_ticks` per turn and advances +a tier once it crosses `STABILITY_TICKS_PER_TIER` (T1→T2 = 50 turns; the T9→T10 crossing is +additionally gated on `tile.ecosystem_event_gate_met`). Because `WorldSim::step` calls +`ecology.process_step` every played turn (`lib.rs:186`), forests/grasslands succeed forward +across real game turns. The advanced tiers persist via `EcologyContinuationState` +(`tile_populations`, including each slot's `tier`/`stability_ticks`, round-trips through the +save), and the succession sequence is deterministic under `SeedDomain::WorldsimDynamics`. +Pinned by `mc-worldsim` GUT-adjacent crate tests `flora_tier_advances_over_played_turns` +(tier strictly rises over 60 played turns), `flora_succession_state_persists` (advanced tier +survives the continuation-state round-trip), and `flora_succession_is_deterministic` (same +seed → identical tier sequence). + +> **Open (g2-07 residual):** tier advancement does **not yet emit a chronicle event**. +> `run_tier_advancement` mutates `slot.tier` silently; there is no `EcologyEvent::FloraTransition` +> (or equivalent) pushed to the `Chronicle` the way geological/biological/anomalous world events +> are in `dispatch_world_events`. Surfacing succession transitions in the playable game log +> (g2-07 acceptance) + the renderer-side N-turn succession proof screenshot remain a +> domain/presentation handoff before g2-07 can close. + **Trophic cascade.** A top-down cascade is emergent, not scripted: collapse a tile's `habitat_suitability` and the prey base crashes; the predator, lacking `prey[]`, declines on a **lag** (it lives off residual prey until starvation), diff --git a/src/game/engine/tests/integration/test_capture_raze_view_reresolution.gd b/src/game/engine/tests/integration/test_capture_raze_view_reresolution.gd new file mode 100644 index 00000000..e7f2e57d --- /dev/null +++ b/src/game/engine/tests/integration/test_capture_raze_view_reresolution.gd @@ -0,0 +1,118 @@ +extends GutTest +## p2-72b — capture / raze view re-resolution. +## +## The `CityScript` thin view caches its `(pi, ci)` index but MUST re-resolve +## `ci` from the city's stable `id` on every access, because: +## • razing a non-last city shifts every later index down by one, and +## • capturing a city moves it to a different player's row. +## +## This integration test drives the real `GdGameState` parallel-slot surface +## (`spawn_city` / `remove_city` / `move_city` / `city_index_by_id` / +## `city_locate_by_id` / `city_dict`) and proves a later-indexed view still +## reads ITS OWN stats — never the shifted neighbour's — after both mutations. + + +func _skip_if_extension_absent() -> bool: + if not ClassDB.class_exists("GdGameState"): + pending("api-gdext GDExtension not loaded (GdGameState absent) — skipping") + return true + return false + + +func _make_state() -> RefCounted: + var state: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted + state.call("create_grid", 20, 20) + # Two players: player 0 founds the multi-city row, player 1 is the captor. + state.call("add_empty_player_with_axes", {}) + state.call("add_empty_player_with_axes", {}) + return state + + +## Read a parallel-slot city's identifying + stat fields as a Dictionary. +func _dict(state: RefCounted, pi: int, ci: int) -> Dictionary: + return state.call("city_dict", pi, ci) as Dictionary + + +func test_raze_non_last_city_keeps_later_view_resolving_its_own_stats() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + + # Found three cities for player 0 with DISTINCT population so a mis-resolved + # index reads an obviously-wrong city. + var i0: int = int(state.call("spawn_city_with_population", 0, "Alpha", 2, 2, true, 1, 3)) + var i1: int = int(state.call("spawn_city_with_population", 0, "Beta", 5, 5, false, 1, 7)) + var i2: int = int(state.call("spawn_city_with_population", 0, "Gamma", 8, 8, false, 1, 5)) + assert_eq(i0, 0, "Alpha founded at index 0") + assert_eq(i1, 1, "Beta founded at index 1") + assert_eq(i2, 2, "Gamma founded at index 2") + + # The later-indexed view (Gamma) caches its stable id. + var gamma_dict: Dictionary = _dict(state, 0, i2) + var gamma_id: String = String(gamma_dict.get("id", "")) + assert_ne(gamma_id, "", "Gamma must carry a stable id") + assert_eq(int(gamma_dict.get("population", -1)), 5, "Pre-raze: Gamma pop is 5") + + # Raze the NON-LAST city (Beta, index 1). Every later index shifts down. + state.call("remove_city", 0, i1) + assert_eq( + int(state.call("presentation_city_count", 0)), + 2, + "Player 0 has two cities after razing Beta", + ) + + # A stale cached index (2) now points past the end OR at the wrong city. + # The view re-resolves Gamma's id → its NEW index (1). + var gamma_new_ci: int = int(state.call("city_index_by_id", 0, gamma_id)) + assert_eq(gamma_new_ci, 1, "Gamma re-resolves to its shifted index 1") + + # Reading at the re-resolved index returns GAMMA's stats — not the + # neighbour that now occupies the old index. + var gamma_after: Dictionary = _dict(state, 0, gamma_new_ci) + assert_eq(String(gamma_after.get("id", "")), gamma_id, "Re-resolved city is Gamma by id") + assert_eq(int(gamma_after.get("population", -1)), 5, "Post-raze: Gamma still reads pop 5") + assert_eq(String(gamma_after.get("city_name", "")), "Gamma", "Post-raze: name is Gamma") + + # Sanity: the surviving Alpha (index 0) is untouched and distinct. + var alpha_after: Dictionary = _dict(state, 0, 0) + assert_eq(int(alpha_after.get("population", -1)), 3, "Alpha pop unchanged at 3") + assert_eq(String(alpha_after.get("city_name", "")), "Alpha", "Index 0 is still Alpha") + + +func test_captured_city_view_reads_correct_stats_under_new_owner() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + + # Player 0 owns two cities; player 1 will capture one of them. + var _cap: int = int(state.call("spawn_city_with_population", 0, "Capital", 2, 2, true, 1, 6)) + var prize_ci: int = int(state.call("spawn_city_with_population", 0, "Prize", 9, 9, false, 1, 4)) + + var prize_dict: Dictionary = _dict(state, 0, prize_ci) + var prize_id: String = String(prize_dict.get("id", "")) + assert_eq(int(prize_dict.get("population", -1)), 4, "Pre-capture: Prize pop is 4") + + # Capture: move the city from player 0's row into player 1's row. + var new_ci: int = int(state.call("move_city", 0, prize_ci, 1)) + assert_gte(new_ci, 0, "move_city returns a valid destination index") + assert_eq( + int(state.call("presentation_city_count", 0)), + 1, + "Player 0 lost the captured city", + ) + assert_eq( + int(state.call("presentation_city_count", 1)), + 1, + "Player 1 gained the captured city", + ) + + # A CityScript view whose owning player also shifted re-locates by id. + var loc: Vector2i = state.call("city_locate_by_id", prize_id) as Vector2i + assert_eq(loc.x, 1, "Prize now located under player 1") + assert_eq(loc.y, new_ci, "Prize located at its new index") + + # The view reads correct stats under the NEW owner. + var prize_after: Dictionary = _dict(state, loc.x, loc.y) + assert_eq(String(prize_after.get("id", "")), prize_id, "Located city is Prize by id") + assert_eq(int(prize_after.get("population", -1)), 4, "Post-capture: Prize still reads pop 4") + assert_eq(String(prize_after.get("city_name", "")), "Prize", "Post-capture: name is Prize") diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs index 43f449a6..c7b799a5 100644 --- a/src/simulator/crates/mc-worldsim/src/lib.rs +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -500,4 +500,141 @@ mod tests { save/load is not determinism-transparent" ); } + + // ── g2-07: flora succession runs in the PLAYED turn ────────────────────── + + const FLORA_ID: u32 = 500; + const FLORA_TILE: (i32, i32) = (8, 8); + + /// A hardy flora (`Diet::Producer`) species for the succession tests. + fn make_succession_flora() -> mc_ecology::species::Species { + use mc_ecology::species::Species; + use mc_ecology::traits::{ + Diet, Habitat, Locomotion, Reproduction, Size, Social, Thermal, TraitSet, + }; + Species::derive_from_traits( + FLORA_ID, + "succession_grass".to_string(), + TraitSet { + size: Size::Small, + diet: Diet::Producer, + habitat: Habitat::Terrestrial, + locomotion: Locomotion::Sessile, + reproduction: Reproduction::RStrategy, + thermal: Thermal::ColdBlooded, + social: Social::Colony, + }, + ) + } + + /// Build a played-turn fixture: a `WorldSim` with one flora species seeded + /// at `FLORA_TILE`, and a `GameState` whose grid forces that tile to + /// maximally favourable habitat so tier advancement is unblocked. The sim + /// reads `state.grid` each `step`, so the tile fields must live on the + /// state's grid (not the worldsim). + fn make_flora_fixture(seed: u64) -> (WorldSim, GameState) { + use mc_ecology::population::PopulationSlot; + + let mut sim = make_worldsim_seeded(seed); + sim.ecology.register_species(make_succession_flora()); + // Robust population → stays above the crash threshold (10% of K) every + // tick, so advancement depends purely on stable played-turn ticks. + sim.ecology + .seed_population(FLORA_TILE.0, FLORA_TILE.1, PopulationSlot::new(FLORA_ID, 200.0)); + + let mut state = make_state(); + if let Some(grid) = state.grid.as_mut() { + if let Some(tile) = grid.tile_mut(FLORA_TILE.0, FLORA_TILE.1) { + tile.habitat_suitability = 1.0; + tile.habitat_low_turns = 0; + tile.terrain_tier_cap = 10; + } + } + (sim, state) + } + + /// Read the tier of the succession flora species at `FLORA_TILE`, if present. + fn flora_tier(sim: &WorldSim) -> Option { + sim.ecology() + .tile_populations + .get(&FLORA_TILE) + .and_then(|slots| slots.iter().find(|s| s.species_id == FLORA_ID)) + .map(|s| s.tier) + } + + /// g2-07 acceptance — flora succession actually advances over **played** + /// turns (via `WorldSim::step` → `ecology.process_step` → + /// `run_tier_advancement`), not only at worldgen. The species starts at + /// tier 1; after enough stable played turns it must reach a higher tier. + #[test] + fn flora_tier_advances_over_played_turns() { + let (mut sim, mut state) = make_flora_fixture(SEED); + + let start = flora_tier(&sim).expect("flora seeded at tier 1"); + assert_eq!(start, 1, "succession species must start at tier 1"); + + // 60 played turns > the 50-tick T1→T2 stability threshold. + for _ in 0..60 { + sim.step(&mut state); + } + + let end = flora_tier(&sim).expect("flora still present after 60 played turns"); + assert!( + end > start, + "flora tier must advance over played turns (start {start}, end {end})" + ); + } + + /// g2-07 acceptance — succession state persists through a serialize → + /// deserialize of the ecology continuation state (the form `mc-save` + /// round-trips), and the restored tier matches. + #[test] + fn flora_succession_state_persists() { + let (mut sim, mut state) = make_flora_fixture(SEED); + for _ in 0..60 { + sim.step(&mut state); + } + let advanced = flora_tier(&sim).expect("flora present"); + assert!(advanced > 1, "precondition: tier advanced before persistence check"); + + // Serialize the continuation state the save layer persists, restore into + // a fresh engine, and confirm the succession tier survived. + let cont = sim.ecology().continuation_state(); + let json = serde_json::to_string(&cont).expect("serialize ecology continuation state"); + let restored: mc_ecology::EcologyContinuationState = + serde_json::from_str(&json).expect("deserialize continuation state"); + + let mut sim2 = make_worldsim_seeded(SEED); + sim2.ecology.register_species(make_succession_flora()); + sim2.ecology.restore_continuation_state(restored); + + assert_eq!( + flora_tier(&sim2), + Some(advanced), + "succession tier must survive the ecology continuation-state round-trip" + ); + } + + /// g2-07 acceptance — succession is deterministic: same `seed` → identical + /// tier sequence across played turns. + #[test] + fn flora_succession_is_deterministic() { + fn run(seed: u64) -> Vec { + let (mut sim, mut state) = make_flora_fixture(seed); + let mut seq = Vec::with_capacity(60); + for _ in 0..60 { + sim.step(&mut state); + seq.push(flora_tier(&sim).unwrap_or(-1)); + } + seq + } + + let a = run(SEED); + let b = run(SEED); + assert!( + a.iter().any(|&t| t > 1), + "tier sequence must show advancement — test would be vacuous otherwise" + ); + assert_eq!(a, b, "same seed must produce an identical succession tier sequence"); + } }