feat(@projects/@magic-civilization): ✨ add bunker river gap blocking logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
a94c0f18e5
commit
257d16eee5
3 changed files with 239 additions and 1 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue