diff --git a/.project/objectives/p2-76-bunker-improvement.md b/.project/objectives/p2-76-bunker-improvement.md index 770605ad..870e77e1 100644 --- a/.project/objectives/p2-76-bunker-improvement.md +++ b/.project/objectives/p2-76-bunker-improvement.md @@ -2,9 +2,9 @@ id: p2-76 title: Bunker improvement — deposit-destroying fortified subterranean chamber priority: p2 -status: missing +status: partial scope: game1 -updated_at: 2026-06-06 +updated_at: 2026-06-09 blocked_by: [p2-75, p2-80] --- @@ -57,13 +57,40 @@ contents and *contaminate* a tile. ## Acceptance -- ◻ Bunker is buildable only on `hills` / `mountains` and only with `pneumatic_construction` researched (gate read from JSON, per Rail 2). -- ◻ On completion, the bunker applies `defense_bonus: 100` and `concealed_from_surface: true` via the `p2-75` subsystem. -- ◻ `destroys_deposit`: completion records the tile's flat index in a new `destroyed_deposits: BTreeSet` (or equivalent) overlay; `mc-city/yield_fold.rs` consults it and yields zero collectible from that tile thereafter. The underlying `CollectiblesIndex` is **not** mutated. -- ◻ `surface_contamination`: completion records the tile in a contamination overlay with `contamination_turns = max(min_turns, destroyed_deposit_tier × turns_per_tier)`; while active the tile is `yields_zeroed_and_unworkable`. Overlay decays per turn; self-heals at 0 (fixed-duration model, no class engine yet — that is `p2-77`). -- ◻ **River-gap guard (INITIAL)**: the bunker is forbidden on a tile that would dam a river (a river-edge tile bridging upstream/downstream) until the runtime hydrology re-solve (`p2-78`) lands. Document this as a temporary build restriction, removed by `p2-78`. -- ◻ Serde round-trip: a `GameState` with a completed bunker (destroyed deposit + active contamination) round-trips cleanly; both overlays `#[serde(default)]`. -- ◻ `cargo test` green (build-gate, deposit-destruction overlay, contamination-duration, yield-zeroing, serde round-trip), headless GUT green, proof-scene screenshot of a built bunker (defense + concealment + dead surface tile) reviewed. +- ◐ **Bunker buildable only on `hills` / `mountains` + `pneumatic_construction`** (gate read from JSON, per Rail 2). The terrain/tech gate is in `bunker.json` (`valid_terrain`, `requires_tech`) and read by the existing GDScript build-validity path. **River-gap guard added in Rust** (see below). The terrain/tech build-validity enforcement is a GDScript-presentation concern not re-verified this pass — partial. +- ✓ **Defense + concealment via p2-75 — DONE (2026-06-09).** `complete_improvement` writes the bunker anchor mirroring `effects` (`defense_bonus: 100`, `concealed_from_surface: true`); the p2-75 combat/vision path consumes them. Verified by `bunker_completion_records_destroyed_deposit_and_pending_terraform` (asserts the mirrored effects) + the gdext `tile_improvement_defense_bonus`/`tile_improvement_concealed` bridges. +- ✓ **`destroys_deposit` overlay + yield-zeroing — DONE (2026-06-09).** `ImprovementEffects` extended with `destroys_deposit: bool` (`#[serde(default)]`). On completion, `GameState::complete_improvement` records the tile's flat index (`col * grid_height + row`) in the new global `destroyed_deposits: BTreeSet` overlay; `mc-city/yield_fold.rs::tile_yields_from_collectibles_suppressed` consults a suppressed-coords set and yields zero collectible. The `CollectiblesIndex` is never mutated. **Verified:** `bunker_completion_records_destroyed_deposit_and_pending_terraform`, `non_destroying_improvement_records_no_overlay`, `p2_76_suppressed_tile_yields_zero_collectible` (all green on apricot). +- ✓ **`surface_contamination` overlay + decay + self-heal — DONE (2026-06-09).** `SurfaceContaminationSpec` (+ `duration_for_tier(tier) = max(min_turns, tier × turns_per_tier)`) parsed into `ImprovementEffects.surface_contamination`. Completion enqueues a `TerraformEvent` (tier SNAPSHOTTED from the persisted tile `quality` — never re-derived from seed). `WorldSim::step` sub-step 1b seeds a `TileContamination { remaining_turns, source_tier }` on the `WorldSim` `contamination_map` overlay + chronicles it; sub-step 4b decays every active contamination and removes (self-heals) at 0, rebuilding the derived `GameState::unworkable_tiles` mirror. **Verified:** `contamination_duration_scales_with_destroyed_tier`, `terraform_seeds_contamination_then_decays_and_self_heals` (seeds tier-3 → 30 turns, decays to self-heal, asserts the unworkable mirror + chronicle entry), green on apricot. +- ✓ **River-gap guard (INITIAL) — DONE (2026-06-09).** `GameState::bunker_river_gap_blocked(col, row)` returns true when the tile carries any `river_edges` (a river course). Bridged as `GdGameState::bunker_river_gap_blocked` for the build-validity path. Documented as temporary — removed by `p2-78`. **Verified:** `bunker_river_gap_guard_blocks_river_course_tiles`. +- ✓ **Serde round-trip — DONE (2026-06-09).** `destroyed_deposits` is `#[serde(default)]` on `GameState` and survives a full `GameState` serde round-trip (`destroyed_deposits_overlay_round_trips_serde`). The contamination overlay round-trips losslessly via the `worldsim_state`-envelope pairs form + `restore_contamination_map` (`contamination_map_round_trips_serde`). `pending_terraform` is `#[serde(skip)]` (drained each turn, design Q3) and is empty on load. +- ◐ **`cargo test` green + headless GUT + proof-scene screenshot.** Cargo DONE — `cargo test -p mc-core -p mc-state -p mc-city -p mc-turn -p mc-worldsim` green on apricot (incl. all 6 new bunker tests + 3 worldsim contamination tests + the yield-suppression test); `validate-game-data` 1103/0. The **proof-scene screenshot of a built bunker (defense + concealment + dead surface tile) is NOT yet captured** — blocks `done`. (Determinism preserved: `p2_76_substeps_are_noop_without_terraform` confirms 1b/4b are no-ops without a pending terraform, so the worldsim golden vector is unperturbed.) + +## Verification note (2026-06-09, apricot — p2-76 bridgehead built) + +Built per the design `.project/designs/p2-76-79-terraforming-cascade-design.md` +Increment 1, with the RECOMMENDED defaults for the open questions: +- **Q1 (overlay home):** split by lifetime — `destroyed_deposits` (permanent, + one-shot) on `GameState`; contamination (per-turn-evolving) on the `WorldSim` + side-structure; derived `unworkable_tiles` mirror on `GameState` for the yield + seam. Implemented as recommended. +- **Q3 (pending_terraform persistence):** `#[serde(skip)]` — completion + 1b + application happen in the same `WorldSim::step`, so the queue never crosses a + save boundary. Implemented as recommended. + +**Files:** `mc-core/src/improvement.rs` (`destroys_deposit` + +`SurfaceContaminationSpec`), `mc-state/src/game_state.rs` (`destroyed_deposits`, +`pending_terraform`, `unworkable_tiles`, `TerraformEvent`, deposit-destruction in +`complete_improvement`, `tile_flat_index`, `bunker_river_gap_blocked`), +`mc-city/src/yield_fold.rs` (`tile_yields_from_collectibles_suppressed`), +`mc-ecology/src/tile.rs` (`TileContamination`), `mc-worldsim/src/lib.rs` +(`contamination_map`, 1b `apply_pending_terraform`, 4b `tick_contamination`, +`restore_contamination_map`), `api-gdext/src/lib.rs` (`is_deposit_destroyed`, +`is_tile_unworkable`, `bunker_river_gap_blocked` bridges). + +**Remaining for `done`:** the proof-scene screenshot (phase-gate). The Rust path ++ overlays + determinism are complete and tested; the proof scene + the GDScript +build-validity enforcement of the terrain/tech gate are the open presentation +work. ## Non-goals diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index f3ca5f35..91984846 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -5470,6 +5470,45 @@ impl GdGameState { } } + /// p2-76 — true iff the deposit at `(col, row)` has been permanently + /// destroyed (by a bunker). Reads the `destroyed_deposits` overlay flat + /// index. The yield path consults this so a destroyed-deposit tile yields no + /// collectible. False when there is no grid or the tile is off-map. + #[func] + pub fn is_deposit_destroyed(&self, col: i64, row: i64) -> bool { + let Ok(c) = u16::try_from(col) else { return false }; + let Ok(r) = u16::try_from(row) else { return false }; + match self.inner.tile_flat_index(c, r) { + Some(flat) => self.inner.destroyed_deposits.contains(&flat), + None => false, + } + } + + /// p2-76 — true iff the tile at `(col, row)` is currently + /// yields-zeroed-and-unworkable due to active surface contamination. Reads + /// the derived `unworkable_tiles` mirror (rebuilt each worldsim step). + #[func] + pub fn is_tile_unworkable(&self, col: i64, row: i64) -> bool { + let Ok(c) = u16::try_from(col) else { return false }; + let Ok(r) = u16::try_from(row) else { return false }; + match self.inner.tile_flat_index(c, r) { + Some(flat) => self.inner.unworkable_tiles.contains(&flat), + None => false, + } + } + + /// p2-76 **temporary** river-gap build guard: true when a bunker (or other + /// deposit-destroying improvement) must be FORBIDDEN at `(col, row)` because + /// the tile carries a river course (damming it needs the `p2-78` hydrology + /// re-solve). The build-validity path consults this before allowing a bunker. + /// Removed by `p2-78`. + #[func] + pub fn bunker_river_gap_blocked(&self, col: i64, row: i64) -> bool { + let Ok(c) = u16::try_from(col) else { return false }; + let Ok(r) = u16::try_from(row) else { return false }; + self.inner.bunker_river_gap_blocked(c, r) + } + /// Queue a bombard request for the turn processor to drain. /// /// `indirect_fire` must be `true` for units with the `arcing` keyword