feat(@projects/@magic-civilization): add bunker river gap blocking logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 22:58:46 -07:00
parent a94c0f18e5
commit 257d16eee5
3 changed files with 239 additions and 1 deletions

View file

@ -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));

View file

@ -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);
}

View file

@ -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);
// 24. 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);
// 24. 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)]