diff --git a/src/simulator/crates/mc-city/src/lib.rs b/src/simulator/crates/mc-city/src/lib.rs index d4d3c69a..314d6344 100644 --- a/src/simulator/crates/mc-city/src/lib.rs +++ b/src/simulator/crates/mc-city/src/lib.rs @@ -102,7 +102,8 @@ pub use recipes::{ }; pub use yield_fold::{ - tile_yields_from_collectibles, CollectiblesIndex, ResourceYieldMap, ResourceYields, + tile_yields_from_collectibles, tile_yields_from_collectibles_suppressed, + CollectiblesIndex, ResourceYieldMap, ResourceYields, }; // Re-export biome-yield types diff --git a/src/simulator/crates/mc-city/src/yield_fold.rs b/src/simulator/crates/mc-city/src/yield_fold.rs index e33118aa..d8038a3b 100644 --- a/src/simulator/crates/mc-city/src/yield_fold.rs +++ b/src/simulator/crates/mc-city/src/yield_fold.rs @@ -149,4 +149,35 @@ mod tests { assert_eq!(a.iter().map(|t| t.coord).collect::>(), b.iter().map(|t| t.coord).collect::>()); } + + #[test] + fn p2_76_suppressed_tile_yields_zero_collectible() { + // Tile (3,3): destroyed deposit / contaminated → zero yield. + // Tile (3,4): untouched → normal yield. The CollectiblesIndex is NOT + // mutated; suppression is a read-time overlay consult. + let mut index = CollectiblesIndex::new(); + index.insert( + (3, 3), + vec![CollectibleRoll { resource_id: "iron_ore".into(), quantity: 2, quality: 1 }], + ); + index.insert( + (3, 4), + vec![CollectibleRoll { resource_id: "iron_ore".into(), quantity: 2, quality: 1 }], + ); + let mut resource_map = ResourceYieldMap::new(); + resource_map.insert("iron_ore".into(), iron_ore_yields()); + + let suppressed = BTreeSet::from([(3, 3)]); + let yields = tile_yields_from_collectibles_suppressed(&index, &resource_map, &suppressed); + + // Suppressed tile still present (worked-tile accounting unchanged) but zero. + let t33 = yields.iter().find(|t| t.coord == (3, 3)).expect("suppressed tile present"); + assert_eq!(t33.production, 0.0, "destroyed/contaminated tile yields zero collectible"); + // Untouched tile keeps its yield. + let t34 = yields.iter().find(|t| t.coord == (3, 4)).unwrap(); + assert!((t34.production - 6.0).abs() < 1e-9, "untouched tile unaffected"); + + // The index itself was never mutated. + assert_eq!(index[&(3, 3)].len(), 1, "CollectiblesIndex not mutated by suppression"); + } } diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs index 7d9276ac..b29cc5ad 100644 --- a/src/simulator/crates/mc-worldsim/src/lib.rs +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -794,4 +794,112 @@ mod tests { ); assert_eq!(a, b, "same seed must produce an identical succession tier sequence"); } + + // ── p2-76 — terraforming cascade bridgehead (1b seed / 4b decay) ───────── + + use mc_core::improvement::SurfaceContaminationSpec; + use mc_state::game_state::TerraformEvent; + use mc_ecology::tile::TileContamination; + + fn bunker_contamination_spec() -> SurfaceContaminationSpec { + SurfaceContaminationSpec { + duration_basis: "destroyed_deposit_tier".to_string(), + turns_per_tier: 10, + min_turns: 10, + tile_effect: "yields_zeroed_and_unworkable".to_string(), + } + } + + /// 1b seeds the contamination overlay from a pending TerraformEvent (tier × + /// turns_per_tier), and chronicles it; 4b decays it; the tile self-heals at 0. + #[test] + fn terraform_seeds_contamination_then_decays_and_self_heals() { + let mut sim = make_worldsim(); + let mut state = make_state(); + // Enqueue a tier-3 destruction at (8,8) → 30 contamination turns. + state.pending_terraform.push(TerraformEvent { + col: 8, + row: 8, + destroyed_tier: 3, + contamination: Some(bunker_contamination_spec()), + }); + + // First step: 1b seeds, 4b decays once (30 → 29). + sim.step(&mut state); + assert!(state.pending_terraform.is_empty(), "1b drains pending_terraform"); + let c = sim.contamination_map().get(&(8, 8)).expect("contamination seeded"); + assert_eq!(c.remaining_turns, 29, "30 seeded, decayed once this turn"); + assert_eq!(c.source_tier, 3); + // Derived unworkable mirror carries the flat index. + let h = state.grid.as_ref().unwrap().height as u32; + assert!(state.unworkable_tiles.contains(&(8 * h + 8)), "unworkable mirror rebuilt"); + // Chronicle carries the contamination event. + assert!( + sim.chronicle.entries().iter().any(|e| matches!( + e, + ChronicleEntry::WorldEvent { category, kind, .. } + if category == "terraform" && kind == "surface_contamination" + )), + "contamination chronicle entry pushed" + ); + + // Run to expiry: 29 more steps → remaining hits 0 and the entry self-heals. + for _ in 0..29 { + sim.step(&mut state); + } + assert!( + sim.contamination_map().get(&(8, 8)).is_none(), + "contamination self-heals at 0 (fixed-duration model)" + ); + assert!( + !state.unworkable_tiles.contains(&(8 * h + 8)), + "self-healed tile removed from the unworkable mirror" + ); + } + + /// The contamination overlay round-trips losslessly through serde (the + /// `worldsim_state` save envelope), restored via `restore_contamination_map`. + #[test] + fn contamination_map_round_trips_serde() { + let mut sim = make_worldsim(); + let mut state = make_state(); + state.pending_terraform.push(TerraformEvent { + col: 4, + row: 5, + destroyed_tier: 6, + contamination: Some(bunker_contamination_spec()), + }); + sim.step(&mut state); + assert!(!sim.contamination_map().is_empty(), "precondition: contamination active"); + + // Serialize the overlay as the save layer would (pairs — tuple keys). + let pairs: Vec<(&(u16, u16), &TileContamination)> = + sim.contamination_map().iter().collect(); + let json = serde_json::to_string(&pairs).expect("serialize contamination_map"); + let restored_pairs: Vec<((u16, u16), TileContamination)> = + serde_json::from_str(&json).expect("deserialize"); + let restored: BTreeMap<(u16, u16), TileContamination> = + restored_pairs.into_iter().collect(); + + let mut sim2 = make_worldsim(); + sim2.restore_contamination_map(restored); + assert_eq!( + sim2.contamination_map(), + sim.contamination_map(), + "contamination overlay survives the save round-trip" + ); + } + + /// Determinism guard: the p2-76 sub-steps do not perturb the worldsim + /// trajectory when no terraform is pending (empty 1b/4b are no-ops). + #[test] + fn p2_76_substeps_are_noop_without_terraform() { + let mut sim = make_worldsim(); + let mut state = make_state(); + for _ in 0..5 { + sim.step(&mut state); + } + assert!(sim.contamination_map().is_empty(), "no contamination without terraform"); + assert!(state.unworkable_tiles.is_empty(), "no unworkable tiles without terraform"); + } }