From 257d16eee50cf7acd9abbd850c9c220d9bcbab45 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 22:58:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20bunker=20river=20gap=20blocking=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-state/src/game_state.rs | 17 +++ .../crates/mc-turn/src/improvement_tests.rs | 137 ++++++++++++++++++ src/simulator/crates/mc-worldsim/src/lib.rs | 86 ++++++++++- 3 files changed, 239 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 2c5615ee..e8211de7 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -727,6 +727,23 @@ impl GameState { Some(u32::from(col) * (grid.height as u32) + u32::from(row)) } + /// p2-76 **temporary** river-gap build guard: true when a deposit-destroying + /// improvement (the bunker) must be FORBIDDEN at `(col, row)` because the + /// tile carries a river course and damming it would require the runtime + /// hydrology re-solve (`p2-78`). A tile "dams a river" when it has any + /// `river_edges` (it is on a river course). Removed by `p2-78` once + /// `resolve_local` can resolve the flood/parch — until then, blocking the dam + /// case is the documented restriction (p2-76 acceptance). Returns false when + /// there is no grid or the tile is off-map. + #[must_use] + pub fn bunker_river_gap_blocked(&self, col: u16, row: u16) -> bool { + self.grid + .as_ref() + .and_then(|g| g.tile(i32::from(col), i32::from(row))) + .map(|t| !t.river_edges.is_empty()) + .unwrap_or(false) + } + /// Remove the improvement at `(col, row)` entirely. pub fn remove_improvement(&mut self, col: u16, row: u16) { self.tile_improvements.remove(&(col, row)); diff --git a/src/simulator/crates/mc-turn/src/improvement_tests.rs b/src/simulator/crates/mc-turn/src/improvement_tests.rs index a5a3581f..bc930a55 100644 --- a/src/simulator/crates/mc-turn/src/improvement_tests.rs +++ b/src/simulator/crates/mc-turn/src/improvement_tests.rs @@ -238,3 +238,140 @@ fn standing_per_turn_effects_parse_from_canonical_json() { assert!(!b.prevents_erosion, "absent → false"); assert!(b.is_empty(), "no effects set → is_empty"); } + +// ── p2-76 — bunker deposit-destruction + surface-contamination overlays ────── + +use mc_core::improvement::SurfaceContaminationSpec; + +/// A bunker-shaped spec: destroys the deposit + fixed-duration contamination +/// (mirrors `public/resources/improvements/bunker.json`). +fn bunker_spec() -> TileImprovementSpec { + TileImprovementSpec { + id: "bunker".to_string(), + hp: 75, + severable: false, + flags: BTreeSet::new(), + effects: ImprovementEffects { + defense_bonus: 100, + concealed_from_surface: true, + destroys_deposit: true, + surface_contamination: Some(SurfaceContaminationSpec { + duration_basis: "destroyed_deposit_tier".to_string(), + turns_per_tier: 10, + min_turns: 10, + tile_effect: "yields_zeroed_and_unworkable".to_string(), + }), + ..Default::default() + }, + } +} + +/// A 6x6 grid with tile `(col,row)` set to `quality` (the deposit-tier source). +fn grid_with_quality(col: i32, row: i32, quality: i32) -> mc_core::grid::GridState { + let mut grid = mc_core::grid::GridState::new(6, 6); + grid.tile_mut(col, row).expect("tile exists").quality = quality; + grid +} + +#[test] +fn bunker_completion_records_destroyed_deposit_and_pending_terraform() { + let mut gs = GameState::default(); + gs.grid = Some(grid_with_quality(3, 2, 7)); // tier-7 deposit beneath + gs.complete_improvement(3, 2, &bunker_spec()); + + // Standing improvement anchor written + effects mirrored (p2-75 path). + let imp = gs.improvement_at(3, 2).expect("bunker present"); + assert_eq!(imp.effects.defense_bonus, 100); + assert!(imp.effects.concealed_from_surface); + + // destroyed_deposits overlay carries this tile's flat index. + let flat = gs.tile_flat_index(3, 2).expect("flat index"); + assert!(gs.destroyed_deposits.contains(&flat), "deposit recorded destroyed"); + + // pending_terraform enqueued with the tier SNAPSHOTTED from tile.quality (7), + // never re-derived from seed. + assert_eq!(gs.pending_terraform.len(), 1); + let ev = &gs.pending_terraform[0]; + assert_eq!((ev.col, ev.row), (3, 2)); + assert_eq!(ev.destroyed_tier, 7, "tier snapshotted from persisted tile quality"); + assert!(ev.contamination.is_some(), "contamination spec carried"); +} + +#[test] +fn non_destroying_improvement_records_no_overlay() { + let mut gs = GameState::default(); + gs.grid = Some(grid_with_quality(1, 1, 5)); + gs.complete_improvement(1, 1, &fort_spec()); // fort: no destroys_deposit + assert!(gs.destroyed_deposits.is_empty()); + assert!(gs.pending_terraform.is_empty()); +} + +#[test] +fn contamination_duration_scales_with_destroyed_tier() { + let spec = SurfaceContaminationSpec { + duration_basis: "destroyed_deposit_tier".to_string(), + turns_per_tier: 10, + min_turns: 10, + tile_effect: "yields_zeroed_and_unworkable".to_string(), + }; + assert_eq!(spec.duration_for_tier(7), 70, "7 × 10"); + assert_eq!(spec.duration_for_tier(0), 10, "floor at min_turns"); + assert_eq!(spec.duration_for_tier(1), 10, "1 × 10 = 10 (== min)"); + // Non-tier basis falls back to min_turns. + let fixed = SurfaceContaminationSpec { + duration_basis: "fixed".to_string(), + turns_per_tier: 10, + min_turns: 25, + tile_effect: String::new(), + }; + assert_eq!(fixed.duration_for_tier(9), 25); +} + +#[test] +fn bunker_river_gap_guard_blocks_river_course_tiles() { + let mut grid = mc_core::grid::GridState::new(6, 6); + grid.tile_mut(2, 2).expect("tile").river_edges = vec![0, 3]; // on a river course + let mut gs = GameState::default(); + gs.grid = Some(grid); + assert!(gs.bunker_river_gap_blocked(2, 2), "river-course tile must be blocked"); + assert!(!gs.bunker_river_gap_blocked(4, 4), "dry tile must be allowed"); + assert!(!gs.bunker_river_gap_blocked(99, 99), "off-map tile is not blocked"); +} + +#[test] +fn destroyed_deposits_overlay_round_trips_serde() { + let mut gs = GameState::default(); + gs.grid = Some(grid_with_quality(3, 2, 7)); + gs.complete_improvement(3, 2, &bunker_spec()); + let flat = gs.tile_flat_index(3, 2).expect("flat"); + assert!(gs.destroyed_deposits.contains(&flat)); + + let json = serde_json::to_string(&gs).expect("serialize GameState"); + let restored: GameState = serde_json::from_str(&json).expect("deserialize GameState"); + assert!( + restored.destroyed_deposits.contains(&flat), + "destroyed_deposits must survive the save round-trip" + ); + // pending_terraform is #[serde(skip)] (drained each turn) — must be empty on load. + assert!(restored.pending_terraform.is_empty(), "pending_terraform is skip-serialized"); +} + +#[test] +fn real_bunker_json_parses_destroy_and_contamination_effects() { + use mc_core::improvement::RawImprovementJson; + let raw: RawImprovementJson = serde_json::from_str( + r#"{"id":"bunker","hp":75,"effects":{ + "defense_bonus":100,"concealed_from_surface":true,"severable":false, + "destroys_deposit":true, + "surface_contamination":{"duration_basis":"destroyed_deposit_tier", + "turns_per_tier":10,"min_turns":10, + "tile_effect":"yields_zeroed_and_unworkable"}}}"#, + ) + .expect("parse bunker JSON"); + let e = TileImprovementSpec::from_json(&raw).effects; + assert!(e.destroys_deposit, "destroys_deposit parsed"); + let c = e.surface_contamination.expect("contamination spec parsed"); + assert_eq!(c.turns_per_tier, 10); + assert_eq!(c.min_turns, 10); + assert_eq!(c.duration_for_tier(7), 70); +} diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs index 72452c79..7d9276ac 100644 --- a/src/simulator/crates/mc-worldsim/src/lib.rs +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -166,6 +166,23 @@ impl WorldSim { self.ecology.restore_continuation_state(ecology_state); } + /// p2-76 — restore the surface-contamination overlay from a save (mirrors + /// `restore_state` for `eco_map`). The map persists alongside `eco_map` in + /// the `worldsim_state` envelope; both are `BTreeMap`s serialized as pairs. + pub fn restore_contamination_map( + &mut self, + contamination_map: BTreeMap<(u16, u16), TileContamination>, + ) { + self.contamination_map = contamination_map; + } + + /// p2-76 — read-only view of the surface-contamination overlay (for the save + /// serializer + tests). + #[must_use] + pub fn contamination_map(&self) -> &BTreeMap<(u16, u16), TileContamination> { + &self.contamination_map + } + /// Advance the whole world by one turn: discrete game turn, then the /// continuous climate + ecology ticks, then world-event dispatch. /// @@ -174,8 +191,17 @@ impl WorldSim { // 1. Discrete game turn (cities, units, combat, victory). let turn = self.processor.step(state); - // 2–4. Continuous worldsim, only when a real map is present. let turn_idx = state.turn; + + // 1b. Apply pending terraforming (p2-76 — bunker completions this turn). + // `complete_improvement` (in `mc-state`) recorded the destroyed + // deposit's flat index + a `TerraformEvent` carrying the tier + // snapshot; here in `mc-worldsim` we seed the contamination overlay + // (which lives on `WorldSim`, not `GameState`). Drained each turn — + // the queue never crosses a save boundary (design Q3). + self.apply_pending_terraform(state); + + // 2–4. Continuous worldsim, only when a real map is present. let world_events = if let Some(grid) = state.grid.as_mut() { // 2. Climate continuous step. `process_step` is the complete CPU // climate turn — it rebuilds the per-tile work cache (albedo / @@ -223,8 +249,66 @@ impl WorldSim { 0 }; + // 4b. Contamination tick (p2-76 — decay + rebuild the unworkable mirror). + // Decrement every active contamination's `remaining_turns`; remove at + // 0 (self-heal — the fixed-duration model has no tech gate). Then + // rebuild the derived `GameState::unworkable_tiles` mirror the yield + // seam reads. Sparse: iterates only the contamination map. + self.tick_contamination(state, turn_idx); + StepResult { turn, world_events } } + + /// p2-76 sub-step 1b — drain `GameState::pending_terraform` and seed the + /// contamination overlay for each deposit-destroying completion. Deterministic: + /// the queue is drained in insertion order; the contamination duration comes + /// from the tier SNAPSHOTTED at completion (never re-derived from seed). + fn apply_pending_terraform(&mut self, state: &mut GameState) { + let pending = std::mem::take(&mut state.pending_terraform); + for ev in pending { + let Some(spec) = ev.contamination.as_ref() else { + continue; // deposit destroyed but no contamination authored + }; + let turns = spec.duration_for_tier(ev.destroyed_tier); + if turns == 0 { + continue; + } + self.contamination_map.insert( + (ev.col, ev.row), + TileContamination { remaining_turns: turns, source_tier: ev.destroyed_tier }, + ); + // Chronicle the contamination event so the game log shows the + // scorched-earth result, mirroring the world-event dispatch shape. + self.chronicle.push(ChronicleEntry::WorldEvent { + turn: state.turn, + category: "terraform".to_string(), + kind: "surface_contamination".to_string(), + col: i32::from(ev.col), + row: i32::from(ev.row), + severity_milli: i32::from(turns), + }); + } + } + + /// p2-76 sub-step 4b — decay every active contamination by one turn, remove + /// self-healed entries, and rebuild the derived `GameState::unworkable_tiles` + /// mirror (flat indices) that `mc-city/yield_fold.rs` reads. Order-stable + /// (`BTreeMap` iteration). + fn tick_contamination(&mut self, state: &mut GameState, _turn_idx: u32) { + // Decay + self-heal. + self.contamination_map.retain(|_, c| { + c.remaining_turns = c.remaining_turns.saturating_sub(1); + c.remaining_turns > 0 + }); + // Rebuild the derived unworkable mirror from the surviving contamination. + let height = state.grid.as_ref().map(|g| g.height as u32); + state.unworkable_tiles.clear(); + if let Some(h) = height { + for &(col, row) in self.contamination_map.keys() { + state.unworkable_tiles.insert(u32::from(col) * h + u32::from(row)); + } + } + } } #[cfg(test)]