feat(@projects): document parallel simulation design

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-10 04:20:43 -07:00
parent 3ebe54f387
commit 31f88a2e95
7 changed files with 1616 additions and 37 deletions

View file

@ -0,0 +1,640 @@
# Engineering design — phase/round state machine + speculative parallel simulation (`p2-83`)
> **Status: DESIGN.** Build-ready plan, not implementation. No code was edited.
> Modeled on `.project/designs/p2-76-79-terraforming-cascade-design.md`.
>
> **Scope:** objective `.project/objectives/p2-83.md` (`status: missing`,
> `scope: game1`, owner `simulator-infra`). Two coupled deliverables:
> **(A)** a first-class, save-aware game-phase + round-phase state machine, and
> **(B)** speculative parallel computation of player-action-independent turn
> work, overlapped with the human player's deliberation and committed (or
> invalidated) at round end — **byte-identical** to the serial path.
>
> **Concurrency caveat:** another agent is actively modifying
> `mc-worldsim`, `mc-state`, `mc-mapgen`, and `api-gdext` (the p2-76…79
> terraforming cascade). All citations below are read-verified against the
> current checkout, in which p2-76's sub-steps **1b/4b already exist** in
> `WorldSim::step`, but p2-78's `resolve_local` / `HydrologyDelta` do **not**
> yet (grep-verified absent). §4.5 and §7.5 mark every point where this design
> depends on that in-flight work landing.
---
## 0. The one architectural idea
**There is no single turn loop today — there are three**, and the phase/round
state machine is the seam that finally unifies them:
1. **The live game (GDScript)**`turn_manager.gd`
(`src/game/engine/src/autoloads/turn_manager.gd`): a private
`enum Phase { NONE, START, PLAYER_ACTIONS, END_TURN }` (`:37`), implicit
array-order player rotation (`next_player`, `:260` — `current_player_index
+ 1`, no permutation), GDScript-side per-player economy/growth processing
(`end_turn`, `:240-252` — Rail-1 tech debt tracked elsewhere), and a
once-per-full-round worldsim block buried in `next_player()` (`:285-315`):
climate via `proc._process_climate`, ecology via `EcologyState.tick`
(`:295`), events via `WorldsimState.dispatch` (`:309`), then victory check
and autosave.
2. **The headless / Claude-API / bench path (Rust)**
`mc_player_api::dispatch::apply_end_turn`
(`mc-player-api/src/dispatch.rs:345`): drives every AI slot, then runs
`TurnProcessor::step`, then comms passes. Already emits wire
`Event::PhaseChanged { phase: "end_turn" }` (`:352`; enum at
`mc-player-api/src/wire.rs:168`) — a string, not a typed machine.
3. **The unified Rust orchestrator**`WorldSim::step`
(`mc-worldsim/src/lib.rs:190`): `TurnProcessor::step` (1) → terraform
application (1b, `:202`) → climate (2, `:212`) → ecology (3, `:218`) →
world events (4, `:239`) → contamination tick (4b, `:257`). Used by tests
and the p2-80 golden vectors; **not** what the live GDScript loop calls
(the live loop re-implements its ordering by hand, the duplication this
objective gets to retire).
The state machine is **Rust-native (Rail 1)**: a typed `GamePhase` +
`RoundPhase` pair living in `mc-core`, persisted on `GameState` (`mc-state`),
sequenced by a `RoundDriver` in `mc-worldsim` (the only crate allowed to see
all engines at once — see the crate-cycle note at `mc-worldsim/src/lib.rs:1-9`),
mirrored to GDScript through `api-gdext` + EventBus signals. GDScript keeps
executing its presentation-side work *inside* phases but stops *owning* phase
state.
Speculation (B) then becomes almost trivial to place: the
fauna/worldsim rounds (steps 24b — exactly the player-independent block) are
computed on a **snapshot** in a background thread while the human player's
round is open, and committed at round end iff nothing the player did
invalidated the snapshot's inputs. Determinism is guaranteed structurally:
*the speculative path runs the identical function on identical inputs* —
commit-or-discard, never merge.
---
## 1. What exists vs what is genuinely new
| Concern | State | Evidence |
|---|---|---|
| Per-turn Rust orchestration with stable sub-step order | **EXISTS** | `WorldSim::step` (`mc-worldsim/src/lib.rs:190-260`), sub-steps 1/1b/2/3/3b/4/4b |
| Deterministic per-turn worldsim RNG | **EXISTS** | `derive_step(seed, SeedDomain::WorldsimDynamics, &[turn])` (`lib.rs:216-217`; domain `= 9` at `mc-core/src/seed.rs`) |
| Golden-vector + save/load determinism gates | **EXISTS** | `determinism_same_seed_byte_identical`, `save_load_is_determinism_transparent` (`mc-worldsim/src/lib.rs:488`, `:543`) — the regression gate the objective names |
| Phase *signals* (untyped) | **EXISTS (weak)** | GDScript `EventBus.phase_changed(phase: String)` (`event_bus.gd:12`); wire `Event::PhaseChanged` (`wire.rs:168`) |
| Turn-order rotation | **EXISTS (implicit)** | array order in `turn_manager.gd:261` and the `0..n_players` loop in `TurnProcessor::step` (`processor.rs:410`); `GameState.current_player_index: u8` already persisted (`game_state.rs:504`, `#[serde(default)]`) |
| Pinned RNG for a one-time permutation draw | **EXISTS** | `mc_core::seed::{derive, Pcg64}` (inline PCG-64 per `WORLDGEN_RNG.md` §2; relocated to mc-core per the p0-20 note in `seed.rs`) |
| Save envelope that grows compatibly | **EXISTS** | `SaveFile.worldsim_state: Option<serde_json::Value>` pattern (`mc-save/src/format.rs:85`); `#[serde(default)]` discipline throughout `game_state.rs` |
| Snapshotable worldsim side-state | **EXISTS** | `EcologyEngine::continuation_state()` / `restore_continuation_state` + `eco_map`/`contamination_map` restore (`lib.rs:160-184`) — the exact clone surface speculation needs |
| Background compute infra | **EXISTS (partial)** | rayon in-pass (`mc-compute` `parallel` feature, `mc-ecology`); thread-count calibration `mc_core::perf::optimal_thread_count`; GPU ecology behind `FORCE_GPU_ECOLOGY` (`mc-ecology/src/engine.rs:311`) |
| **Typed `GamePhase` / `RoundPhase` enums + transition validation** | **NEW** | nothing typed exists; live phase is a GDScript enum, wire phase is a string |
| **Random, persisted turn-order permutation** | **NEW** | rotation is implicit array order everywhere |
| **Single authoritative round sequencer (`RoundDriver`)** | **NEW** | ordering today is duplicated between `turn_manager.gd` and `WorldSim::step` |
| **Speculative executor + invalidation tracker + `SPECULATIVE_TURN` flag** | **NEW** | no precedent in the workspace |
**Net-new engines: zero.** Like the terraforming cascade, this is
re-orchestration of existing passes: one new enum module, one new sequencer
struct, one snapshot/commit wrapper, and bridge/signal plumbing.
---
## 2. The state machine model (deliverable A)
### 2.1 Enums — home: `mc-core` (new module `mc-core/src/phase.rs`)
`mc-core` is the only home every consumer can see without a cycle
(`mc-state` embeds it in `GameState`; `mc-turn`, `mc-worldsim`,
`mc-player-api`, `api-gdext` all read it).
```rust
/// One-time game lifecycle. Monotonic — transitions only move forward.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GamePhase {
WorldGenerating,
WorldReady, // grid attached, players not yet seated
GameStart, // turn order drawn + persisted here
InProgress,
GameOver, // payload lives in TurnResult/end_conditions, not the enum
}
/// Position inside one round. The cursor the sequencer advances.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RoundPhase {
PlayerRound { order_pos: u8 }, // index into turn_order, NOT a player slot
FaunaRound, // ecology / Lotka-Volterra / dispersal (step 3)
WorldsimRound, // climate + world events + contamination (2, 4, 4b)
RoundEnd, // victory check, autosave boundary, chronicle flush
}
```
`PlayerRoundStart/End`, `FaunaRoundStart/End`, etc. from the objective are
**transition events**, not states: the sequencer emits a `PhaseEvent` on every
entry and exit (§3.2). Keeping the state space small (4 round variants) and the
event vocabulary rich (8+ events) avoids a doubled enum.
Transition legality is enforced in `mc-core` (`GamePhase::can_advance_to`,
`RoundPhase::next(&turn_order)`) with `#[must_use]` pure functions — unit-testable
without any engine.
### 2.2 Persisted state — home: `GameState` (`mc-state/src/game_state.rs`)
Three new fields, all `#[serde(default)]` (the proven growth pattern —
`game_state.rs` uses it ~40×):
```rust
/// Game lifecycle phase. Default = InProgress so every pre-p2-83 save
/// (necessarily mid-game) loads correctly.
#[serde(default = "default_game_phase")] // -> GamePhase::InProgress
pub game_phase: GamePhase,
/// Random turn-order permutation drawn once at GameStart. turn_order[k] is
/// the player slot that acts k-th each round. Default = identity permutation
/// (rebuilt on load from players.len()), matching pre-p2-83 array order.
#[serde(default)]
pub turn_order: Vec<u8>,
/// Cursor: where in the current round we are. Default = start-of-round at
/// order_pos 0 — combined with the existing `current_player_index`
/// (game_state.rs:504) this reproduces pre-p2-83 resume behaviour.
#[serde(default)]
pub round_phase: RoundPhase,
```
These ride the existing `GameState` serialization (`serialize_full`
`GdGameState` save path) — no `SAVE_FORMAT_VERSION` bump, matching how
`worldsim_state` was added (`mc-save/src/format.rs:85` rationale comment).
**Mid-round save/resume** falls out: phase + cursor + permutation are ordinary
state fields; the round-trip test (§5, Increment 1 gate) saves at every
`RoundPhase` value and asserts byte-identical continuation.
Speculative results are **never** part of this — a save taken while
speculation is in flight first resolves it (discard; §4.4 rule S3).
### 2.3 The turn-order draw — deterministic, one-time, persisted
At the `GameStart` transition:
```rust
let order_seed = derive(state.map_seed, SeedDomain::GameSetup); // NEW domain = 10
let mut rng = Pcg64::seed_from_u64(order_seed);
state.turn_order = fisher_yates(0..state.players.len() as u8, &mut rng);
```
- **New `SeedDomain::GameSetup = 10`**, appended at the enum tail
(`mc-core/src/seed.rs` — same append-only rule the doc and the
`WorldsimDynamics = 9` precedent establish). Rationale for a new ordinal
rather than a `WorldsimDynamics` discriminant mix (the terraforming design's
Q2 leaned against burning ordinals): this is a *game-lifecycle* draw, not a
worldsim-dynamics draw, and `WORLDGEN_RNG.md` §3's rule is "new pass → new
variant". One ordinal for all future game-setup randomness (starting
positions already have their own path; this domain covers setup draws that
postdate worldgen). **Recommended default — confirm.**
- The permutation is drawn **once**, persisted, and never re-derived (the
p2-76 "snapshot, never re-derive" rule). Loads never re-roll it.
- `WORLDGEN_RNG.md` §3/§12 get a one-line addition when this lands
(docs-and-plan sync, not this design's build scope).
### 2.4 GameOver
`TurnProcessor::step` already computes winners and emits
`TurnEvent::GameOver` with a typed reason (`processor.rs:586-611`). The
sequencer observes `TurnResult.winner` / the GameOver event at `RoundEnd` and
transitions `InProgress → GameOver`. No new victory logic.
---
## 3. The sequencer and the bridge
### 3.1 `RoundDriver` — home: `mc-worldsim`
`mc-worldsim` is the orchestration crate (it exists *because* `mc-turn` can't
see the engines — `lib.rs:1-9`). New struct wrapping `WorldSim`:
```rust
pub struct RoundDriver {
sim: WorldSim,
// Increment 2 adds: speculation: Option<SpeculativeRound>,
}
pub enum PhaseEvent {
GamePhaseChanged { from: GamePhase, to: GamePhase },
RoundStarted { turn: u32 },
PlayerRoundStarted { slot: u8, order_pos: u8 },
PlayerRoundEnded { slot: u8, order_pos: u8 },
FaunaRoundStarted, FaunaRoundEnded,
WorldsimRoundStarted, WorldsimRoundEnded,
RoundEnded { turn: u32, world_events: usize },
}
impl RoundDriver {
/// Advance the cursor one phase, executing the phase's work, returning
/// the events emitted. The ONLY way phase state changes (single writer).
pub fn advance(&mut self, state: &mut GameState) -> Vec<PhaseEvent>;
/// Close the active player's round (the EndTurn entry point).
pub fn end_player_round(&mut self, state: &mut GameState) -> Vec<PhaseEvent>;
}
```
**Phase → work mapping (Increment 1 — wrap, don't decompose):**
| Phase | Executes | Today's site |
|---|---|---|
| `PlayerRound{k}` (human) | nothing in Rust — the window stays open for interactive `mc_player_api::dispatch::apply_action` calls / GDScript input until `end_player_round` | `turn_manager.gd` PLAYER_ACTIONS |
| `PlayerRound{k}` (AI) | the per-slot AI drive (`drive_ai_slot`, `dispatch.rs:373`) | inside `apply_end_turn`'s all-slots loop |
| `FaunaRound` | `WorldSim::step` sub-step 3 (+3b chronicle) | `lib.rs:218-236` |
| `WorldsimRound` | sub-steps 1b, 2, 4, 4b (terraform application, climate, events, contamination) | `lib.rs:202`, `:212`, `:239`, `:257` |
| `RoundEnd` | sub-step 1 tail concerns: victory/end-conditions check, derived stats, chronicle flush, autosave signal | `processor.rs:537-611`; `turn_manager.gd:320-324` |
**Deliberately NOT in Increment 1:** splitting `TurnProcessor::step`'s
internal all-players loop (`processor.rs:410`) into per-player phase slices.
The monolithic step keeps running once per round inside the
`WorldsimRound`/`RoundEnd` boundary exactly as `WorldSim::step` calls it today
— byte-identical output, golden vectors untouched. Re-slicing the processor is
a separate (large) refactor; the phase machine must not gate on it. The
*observable* sequence (signals) is therefore slightly coarser than the ideal
in Increment 1 — `PlayerRoundStart/End` fire per player, but the economic
per-player work still resolves in the round-end batch, same as today.
### 3.2 Signal/event surface to UI
- **GDScript (live game):** `api-gdext` extends `GdWorldSim`
(`api-gdext/src/lib.rs:9369`) — or a sibling `GdRoundDriver` wrapping it —
with `advance() -> Array<Dictionary>` returning the `PhaseEvent` batch.
`turn_manager.gd` translates each event onto **typed EventBus signals**
(new: `game_phase_changed(from: String, to: String)`,
`player_round_started(slot, order_pos)`, `fauna_round_started()`, …),
keeping the legacy `phase_changed(String)` (`event_bus.gd:12`) emitting in
parallel for one transition window, then deleting it (zero-tech-debt rule —
the deletion is part of Increment 1's acceptance, not "later").
`turn_manager.gd`'s private `Phase` enum (`:37-49`) is **deleted**; its
`get_phase_name()` reads the bridge.
- **Headless/wire path:** `mc-player-api` translates `PhaseEvent` into typed
wire events (extend `wire.rs``Event::PhaseChanged` gains structured
variants or a typed payload; the string form `"end_turn"` at
`dispatch.rs:352-354` is replaced, not duplicated).
- **Telemetry:** `auto_play.gd` / p2-84 spans key off the same events —
the phase tag p2-84's trigger-attribution wants (its objective names the
p2-83 machine explicitly).
### 3.3 Live-path migration (the honest hard part)
The live game's per-player GDScript processing (`turn_manager.gd:240-252`,
`_process_culture``_process_government`) and its hand-rolled worldsim block
(`:285-315`) currently *are* the round. Increment 1 makes the Rust sequencer
authoritative for **state + ordering** while letting GDScript keep executing
its presentation-side work inside the phases it's told about:
- `start_turn`/`end_turn`/`next_player` stop deciding order; they call
`GdRoundDriver.advance()` / `end_player_round()` and react to the returned
events.
- The worldsim block (`EcologyState.tick` + `WorldsimState.dispatch` + climate)
moves behind the `FaunaRound`/`WorldsimRound` events — same Rust calls,
now sequenced by the driver instead of inlined in `next_player()`. This is
the convergence point with `WorldSim::step`: the live path and the test
path finally execute the same ordering from the same sequencer.
- The GDScript economy processing stays where it is (Rail-1 debt, tracked by
p0-26 et al.) but runs strictly inside the `PlayerRound` window — no
behaviour change, one owner of "when".
---
## 4. Speculative parallel simulation (deliverable B)
### 4.1 What is speculated
Exactly the `FaunaRound` + `WorldsimRound` work: sub-steps **2, 3, 4, 4b** of
`WorldSim::step`. These read `state.grid` + worldsim-owned side state
(`eco_map`, `contamination_map`, ecology populations) and the per-turn derived
seed. They do **not** read pending player intent. Sub-steps 1 (TurnProcessor)
and 1b (terraform application — *player-caused by definition*) are never
speculated.
### 4.2 The action-independence predicate
Two levels; Increment 2 ships the coarse one, the fine one is specced for the
follow-up and gated on a measured need (§6).
**Coarse (Increment 2 — all-or-nothing):** the speculated block's read set is
`(grid tiles, eco_map, contamination_map, populations, turn, seed)`. The active
player's actions can reach that read set only through an enumerable set of
write channels:
1. grid-tile mutation (improvement completion effects, pillage, city found,
p2-78 hydrology re-solve once it lands),
2. `pending_terraform` enqueue (`game_state.rs:369`),
3. anything that mutates worldsim side-state (none exist on the player-action
path today; the channel is listed so the audit covers it).
Mechanism: a `speculation_epoch: u64` on `GameState` (`#[serde(skip)]`
derived/session state), bumped by each of those write channels at the handler
level (`complete_improvement`, the pillage drain, `push` to
`pending_terraform`, `resolve_local` application). The snapshot records the
epoch; **commit iff epoch is unchanged and `pending_terraform` is empty**.
Because handler enumeration can rot, a dev-build audit (couples with p2-84's
`profiling` feature) hashes the grid before/after each player round and
`debug_assert!`s that an unchanged epoch implies an unchanged hash — the
enumeration is *verified*, not trusted.
**Fine (specced, deferred):** per-tile independence.
- Player-influence set `I` = union over the active player's units of
(current position reachable tiles this turn: movement range + action
radius + combat radius) worked city radii tiles with in-progress
improvements.
- Per-pass coupling radius `r`: climate moisture/runoff propagation reach per
tick, ecology dispersal reach (1 hex/tick), event blast radius max,
contamination spread (1 hex/tick), hydrology window radius (68 per the
p2-78 design §4.2). `r_total = max(...)`, a data-driven constant.
- A tile `t` is **independent** iff `hex_distance(t, I) > r_total`.
Invalidation rule: when an action commits writing tile set `W`, invalidate
speculative results for all tiles within `r_total` of `W`; recompute only
those at round end.
- **Hard precondition** that makes this deferred: partial recompute is only
deterministic if every RNG consumption in the speculated passes is
**per-tile keyed** (the `tile_rng(domain_seed, col, row)` pattern,
`WORLDGEN_RNG.md` §5). Parts of ecology already are (e.g.
`seed_dispersal_hash(tick, col, row, species_id)`,
`mc-ecology/src/engine.rs:1047`), but the full audit of
`process_step`'s sub-passes (population dynamics, dispersal ordering,
emergence) has not been done. Any pass that streams one RNG across tiles in
iteration order would produce different draws under partial recompute →
silent divergence. All-or-nothing sidesteps the audit entirely: the
committed result is the output of the *identical whole-pass call*.
### 4.3 Execution + threading model
**Recommended: snapshot + `std::thread` at the bridge layer; rayon stays
in-pass.**
- `mc-worldsim` gains a pure, thread-free core:
```rust
pub struct WorldSnapshot { // everything sub-steps 24b read or write
grid: GridState, // clone
eco_map: BTreeMap<(u16,u16), TileEcoState>,
contamination_map: BTreeMap<(u16,u16), TileContamination>,
ecology: EcologyContinuationState, // the proven clone surface (lib.rs:160)
turn: u32, seed: u64, epoch: u64,
}
pub struct SpeculativeResult { /* mutated copies of the above + chronicle entries + world_events count */ }
pub fn run_world_round(snapshot: WorldSnapshot) -> SpeculativeResult; // Send + 'static, no &mut aliasing
```
`run_world_round` calls the *same* climate/ecology/dispatch functions
`WorldSim::step` calls — no fork of the pass logic (DRY; the serial path is
refactored to call it too, so there is exactly one implementation).
- `api-gdext` owns the thread: `GdRoundDriver.begin_speculation()` clones the
snapshot and `std::thread::spawn`s `run_world_round`; `end_player_round`
joins the `JoinHandle` (it has the whole human-deliberation window to
finish; if still running at End Turn, joining blocks — worst case equals
today's serial latency, never worse).
- **Not** a Godot `Thread` (GDScript can't own Rust state across the
boundary safely) and **not** an async runtime (none exists in the workspace;
adding tokio for one join is unjustified).
- rayon (`mc-compute` `parallel` feature) keeps parallelizing *inside* passes
on the background thread, sized by `mc_core::perf::optimal_thread_count`,
minus headroom so the render thread isn't starved (a `speculation_threads`
tunable, data-driven). The GPU ecology path (`FORCE_GPU_ECOLOGY`,
`engine.rs:311`) works unchanged on the background thread — it's already a
synchronous dispatch from the caller's perspective.
### 4.4 Commit / invalidate / determinism rules
- **S1 — commit:** at `end_player_round`, if `epoch` unchanged and
`pending_terraform` empty → swap the result's grid fields, side-maps,
population state, and chronicle entries into the live state. Byte-identical
to serial *by construction* (same function, same inputs, same binary).
- **S2 — invalidate:** otherwise discard the entire result and run the world
round serially, exactly as today. No merging, ever.
- **S3 — save boundary:** saving resolves speculation first (discard — cheap,
rare). Speculative state never serializes; `worldsim_state`
(`format.rs:85`) and the GameState fields stay speculation-free.
- **S4 — RNG:** the speculative call derives the identical
`derive_step(seed, WorldsimDynamics, &[turn])` stream (`lib.rs:216`) from
the snapshot's `(seed, turn)` — no new domain, no perturbation. The
`SPECULATIVE_TURN` flag changes *where* the computation runs, never *what*
it computes.
- **S5 — FP determinism:** same binary + same inputs ⇒ identical floats on
the same host, including across threads — **provided no pass has a
scheduling-order-dependent parallel reduction**. The existing
`determinism_same_seed_byte_identical` gate already forbids that for the
current passes; Increment 2 adds the flag-on/flag-off parity test as the
permanent guard (objective acceptance bullet 5).
- **S6 — flag:** `SPECULATIVE_TURN` env/EnvConfig flag (default **off** until
the parity gate has soaked on apricot batches), read once at game start.
### 4.5 Interaction with in-flight p2-78 (windowed hydrology)
The runtime hydrology re-solve (not yet landed; design at
`.project/designs/p2-76-79-terraforming-cascade-design.md` §4) is triggered by
terraform events at sub-step 1b — i.e. **always on the player-dependent side**
of the boundary, and `pending_terraform.is_empty()` is already a commit
condition (S1). When p2-78 lands, its `resolve_local` application site must
bump `speculation_epoch` (it mutates grid hydrology fields). The terraforming
design §7.4 already records this exact classification ("4b is
player-independent; 1b is dependent") — the two designs agree; this one adds
the epoch-bump requirement to p2-78's integration checklist.
---
## 5. Increments + test plans
### Increment 1 — state machine only (no speculation)
**Build:**
1. `mc-core/src/phase.rs``GamePhase`, `RoundPhase`, `PhaseEvent`,
transition functions; `SeedDomain::GameSetup = 10`.
2. `GameState` fields (`game_phase`, `turn_order`, `round_phase`),
`#[serde(default)]`, Fisher-Yates draw at the `GameStart` transition.
3. `RoundDriver` in `mc-worldsim` wrapping `WorldSim` (§3.1 mapping; wraps,
does not decompose `TurnProcessor::step`).
4. `api-gdext` `GdRoundDriver` (or `GdWorldSim` extension) + typed wire events
in `mc-player-api`.
5. `turn_manager.gd` migration: delete the local `Phase` enum, drive through
the bridge, emit typed EventBus signals, move the `next_player()` worldsim
block behind the `FaunaRound`/`WorldsimRound` events.
**Test plan / gates:**
- `cargo test -p mc-core` — transition legality (every illegal transition
rejected; `GamePhase` monotonicity), permutation: same seed → same order,
different seed → different order (load-bearing-seed guard, mirroring
`determinism_different_seed_diverges`, `mc-worldsim/lib.rs:511`).
- `cargo test -p mc-worldsim``RoundDriver` full-round event sequence golden
(ordered `PhaseEvent` list for a 3-player round); **p2-80 golden vectors
unchanged** (`test_worldsim_trajectory_golden_vector`,
`test_continued_trajectory_byte_identical_after_save_load` stay green —
proof the wrapper is behaviour-preserving).
- `mc-save` round-trip extended: save at **every** `RoundPhase` value,
restore, continue to turn N, byte-identical vs never-saved control (the
mid-round-resume acceptance bullet).
- Headless GUT green (`--headless`, Rail 5): turn_manager drives a scripted
3-player round and the EventBus signal sequence matches the Rust golden.
- Proof screenshot per phase-gate-protocol (phase indicator HUD reading the
new typed phase) — run on **apricot** (`scripts/apricot-run.sh`).
### Increment 2 — speculation for fauna/worldsim rounds
**Build:**
1. `WorldSnapshot` / `run_world_round` extraction (serial path refactored to
call it — single implementation).
2. `speculation_epoch` + handler-site bumps + dev-build grid-hash audit.
3. Bridge thread spawn/join in `api-gdext`; `SPECULATIVE_TURN` flag;
S1S3 commit/invalidate/save rules.
4. p2-84 spans on snapshot-clone cost, speculative compute, join-wait, commit
(named, trigger-tagged — the data that decides whether the fine-grained
predicate is ever worth building).
**Test plan / gates:**
- **Parity golden (the headline gate):** N-turn trajectory with
`SPECULATIVE_TURN=on` vs `off`, byte-identical serialized state (the p2-80
capture pattern, `run_capture` at `lib.rs:457`), for (a) a no-action
player, (b) a player whose actions don't touch the grid (move/fortify),
(c) a player who completes an improvement on a deposit (must invalidate →
serial recompute → still identical).
- Invalidation unit tests: epoch bump from each enumerated handler; commit
refused when `pending_terraform` non-empty.
- Save-during-speculation test: save forces discard; round-trip remains
byte-identical.
- Soak: apricot autoplay batch (`tools/autoplay-batch.sh`) with the flag on,
determinism-audit (`tools/determinism-audit.sh`) green.
- **Wall-clock measurement on apricot** (acceptance bullet 6): huge-map
End-Turn → next-playable latency, flag on vs off, quantified via
`tools/measure-turn-latency.py` + p2-84 spans. Target: worldsim-block cost
hidden inside the human deliberation window at high commit rate; report the
measured commit rate alongside.
---
## 6. Do-not-build list (premature)
- **Decomposing `TurnProcessor::step`'s all-players loop** into per-player
phase slices (`processor.rs:410`). Huge blast radius across every phase
handler; the state machine doesn't need it. Revisit only if per-player
economic resolution ordering becomes a designed game mechanic.
- **Fine-grained per-tile speculation** (§4.2). Gated on (a) the per-tile-RNG
audit, (b) p2-84 data showing the all-or-nothing commit rate is actually
low in real games. Build the measurement first.
- **Speculating AI player rounds** (pipelining AI turns during the human
round). AI reads the post-human-action state by design; reordering it is a
game-rules change, not an optimization.
- **Speculating `TurnProcessor::step`** (economy/combat) — player-coupled by
definition.
- **GPU-side speculation scheduling** — the GPU ecology path already rides
the background thread for free; bespoke GPU queueing is premature.
- **Multiplayer/network phase states** — Game 1 is single-human + AI clans.
- **A `GameOver` sub-machine** (ceremony/postgame states) — presentation
concern; GDScript reacts to the single `GameOver` + reason payload.
- **Persisting speculative results across saves** — explicitly forbidden (S3),
not deferred.
---
## 7. Risks
1. **Behavioural drift while wrapping the live GDScript loop.** The
`turn_manager.gd` migration moves *call sites*, not logic, but the live
path has subtle ordering (culture-before-growth comment at `:236-239`,
arena gating at `:333-345`, prologue branch at `:209-229`). Mitigation:
the prologue and arena branches are explicitly carried as
`PlayerRound`-internal concerns (the driver doesn't model them in
Increment 1); GUT signal-sequence golden + existing autoplay batches gate
the migration.
2. **Epoch-bump enumeration rot (the speculation correctness risk).** A new
grid-writing action handler that forgets to bump the epoch silently
commits stale speculation. Mitigation: the dev-build grid-hash audit
(§4.2) runs on every apricot batch; the parity golden (on vs off) is in
the default test suite forever.
3. **Join-wait regressions.** If speculation regularly outlives the human's
deliberation (fast players, huge maps), End Turn blocks on the join and
perceived latency equals serial — fine — but thread-pool contention with
the render thread could make it *worse* than serial. Mitigation: the
`speculation_threads` headroom tunable + p2-84 join-wait span; acceptance
requires measured, not assumed, improvement.
4. **Save-format growth.** Three new `GameState` fields, all
`#[serde(default)]` with semantically-correct defaults for old saves
(`InProgress`, identity order, round start). Round-trip tests at every
phase value. The `turn_order` identity default subtly changes nothing for
old saves because array order *was* the turn order.
5. **In-flux substrate.** `mc-worldsim`/`mc-state`/`api-gdext` are being
modified concurrently (p2-76…79). The `RoundDriver` wraps whatever
`WorldSim::step` looks like when this builds; sub-step 1b/4b are already
in (`lib.rs:202`, `:257`), and the §4.5 epoch-bump requirement must be
added to p2-78's `resolve_local` integration when it lands. Re-verify
citations at build time.
6. **Two phase vocabularies during migration.** Wire `PhaseChanged` strings
(`dispatch.rs:352`) and the GDScript `phase_changed` signal both exist
today; Increment 1 replaces both with the typed vocabulary and deletes the
legacy forms in the same patch series (zero-tech-debt — no permanent
compatibility shim).
---
## 8. Open questions — operator / architecture calls
- **Q1 — Live-path authority boundary.** This design keeps the GDScript
per-player economy processing (`turn_manager.gd:240-252`) executing inside
the `PlayerRound` window, with Rust owning only phase state + ordering.
Confirm that's the intended Increment-1 line, vs. pulling those calls
behind the driver too (bigger, couples with the Rail-1 AI/economy port
backlog).
- **Q2 — `SeedDomain::GameSetup` (new ordinal 10) vs a
`WorldsimDynamics`-mixed discriminant** for the turn-order draw. §2.3
recommends the new domain (lifecycle ≠ dynamics; append-only rule). Every
new ordinal is a permanent save-format commitment — confirm.
- **Q3 — Human slot position in the permutation.** Pure random permutation can
seat the human mid-round (AI acts before them on turn 1). Gameplay-neutral
mechanically, but is "human always first" wanted for UX? Default:
**pure random, no human pinning** (the objective says "RANDOM permutation"
verbatim); a UI affordance can display order.
- **Q4 — `SPECULATIVE_TURN` default-on criteria.** Default off at merge;
proposed flip condition: ≥2 weeks of apricot batches with parity +
determinism-audit green and measured commit rate ≥ ~80% early-game. Confirm
the bar.
- **Q5 — `RoundEnd` autosave placement.** Today autosave fires in
`next_player()` after victory check (`turn_manager.gd:324`), skipped in
arena mode. Default: autosave is a `RoundEnded`-signal *consumer* in
GDScript (presentation-side policy), not a sequencer responsibility.
Confirm.
- **Q6 — Wire-event compatibility window.** Any external consumer of the
string `Event::PhaseChanged` (Claude-API adapter, replay tooling)? Default
assumes the typed replacement can land atomically across this repo's
consumers; confirm nothing external parses the `"end_turn"` string.
---
## 9. Key decisions (summary for the operator)
1. **One sequencer, three paths converged.** `GamePhase`/`RoundPhase` enums in
`mc-core`, persisted on `GameState`, executed by a `RoundDriver` in
`mc-worldsim` that wraps the existing `WorldSim::step` sub-steps without
decomposing `TurnProcessor`. GDScript's hand-rolled phase enum and
worldsim block are deleted; the live game, the bench path, and the test
path finally share one ordering authority. Rail 1 throughout — GDScript
only mirrors.
2. **Turn order = one-time Fisher-Yates draw** under a new
`SeedDomain::GameSetup = 10`, persisted in `turn_order: Vec<u8>`, never
re-derived; identity-permutation serde default keeps old saves exact.
3. **Speculation is all-or-nothing in Increment 2**: the whole
fauna+worldsim block runs on a `WorldSnapshot` in a `std::thread` during
the human's round; commit iff the `speculation_epoch` (bumped by the
enumerated grid-writing handlers, verified by a dev-build grid-hash
audit) is untouched and `pending_terraform` is empty — otherwise discard
and run serially. Byte-identical by construction; the per-tile
independence predicate (influence set + coupling radius `r_total`) is
fully specced but deferred behind a per-tile-RNG audit and p2-84
commit-rate data.
4. **Determinism gates are the acceptance**: p2-80 goldens unchanged
(Increment 1), flag-on/flag-off byte parity + mid-round save/resume parity
(Increment 2), wall-clock win measured on apricot — never assumed.
5. **The biggest deliberate non-goal**: no `TurnProcessor` decomposition and
no per-tile partial recompute until measurement says so. The cheap version
captures the early-game win the operator described (player touches few
tiles → epoch rarely bumps → high commit rate) without the hardest
correctness work.
---
*Design authored against: `mc-worldsim/src/lib.rs` (step + p2-76 sub-steps +
determinism/save-restore tests), `mc-turn/src/processor.rs:392-652`,
`mc-player-api/src/dispatch.rs` (`apply_action`/`apply_end_turn`),
`mc-player-api/src/wire.rs:152-176`,
`src/game/engine/src/autoloads/turn_manager.gd`,
`src/game/engine/src/autoloads/worldsim_state.gd`,
`src/game/engine/src/autoloads/event_bus.gd:10-12`,
`src/game/engine/src/modules/ai/ai_turn_bridge.gd`,
`mc-state/src/game_state.rs` (fields :281-532), `mc-core/src/seed.rs`,
`mc-core/src/perf.rs`, `mc-save/src/format.rs:28-86`,
`public/games/age-of-dwarves/docs/terrain/WORLDGEN_RNG.md`, and
`.project/designs/p2-76-79-terraforming-cascade-design.md`. mc-worldsim /
mc-state / mc-mapgen / api-gdext are under concurrent modification (p2-76…79)
— re-verify line citations at build time.*

View file

@ -12,5 +12,9 @@
"river_threshold": 12,
"max_riparian_distance": 5,
"coarse_grid_threshold": 150
},
"local_resolve": {
"radius": 6,
"dam_raise_to": 2.0
}
}

View file

@ -482,6 +482,44 @@ fn run_hydrology_coarse(
(flow, area, strahler, lake_id, riparian)
}
// ── Slice solver (shared by the baker and the p2-78 runtime re-solve) ─────
/// Output of one full-resolution hydrology solve over a `w × h` elevation
/// slice. Field vectors are row-major, length `w * h`.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SliceHydrology {
pub flow: Vec<HexDir>,
pub area: Vec<u32>,
pub strahler: Vec<u8>,
pub lake_id: Vec<Option<u32>>,
pub riparian: Vec<u8>,
}
/// Run the full-resolution hydrology pass sequence over an elevation slice:
/// D6 flow assignment → Planchon-Darboux fill → drainage accumulation →
/// lake-id assignment → Strahler order → riparian BFS.
///
/// Pure extraction of the `run_hydrology` full-resolution path so the
/// `p2-78` runtime localized re-solve (`hydrology_resolve::resolve_local`)
/// reuses the exact baker algorithm on a bounded window instead of
/// re-implementing it. Deterministic; draws **no** RNG (only the coarse-grid
/// upsampling path uses `SeedDomain::Hydrology`, and windows are always
/// below the coarse threshold).
///
/// The slice border is treated exactly like the map border in the global
/// baker: out-of-slice neighbours read as elevation 0 (sink), and the
/// Planchon-Darboux queue seeds from the slice's border ring.
pub(crate) fn run_hydrology_on_slice(elevation: &[f32], w: i32, h: i32) -> SliceHydrology {
let mut elev = elevation.to_vec();
let mut flow = assign_flow_directions(&elev, w, h);
planchon_darboux_fill(&mut elev, &mut flow, w, h);
let area = compute_drainage_area(&flow, &elev, w, h);
let lake_id = assign_lake_ids(&elev, &flow, w, h);
let strahler = compute_strahler_order(&flow, &area, &elev, w, h);
let riparian = compute_riparian_distance(&area, &lake_id, w, h);
SliceHydrology { flow, area, strahler, lake_id, riparian }
}
// ── Public entry point ─────────────────────────────────────────────────────
/// Run D6 flow analysis, drainage accumulation, lake fill, Strahler order,
@ -500,15 +538,9 @@ pub fn run_hydrology(map_seed: u64, grid: &mut GridState) {
let (flow, area, strahler, lake_id, riparian) = if w > COARSE_THRESHOLD || h > COARSE_THRESHOLD {
run_hydrology_coarse(map_seed, &elevation, w, h)
} else {
// Full-resolution path.
let mut elev = elevation.clone();
let mut flow = assign_flow_directions(&elev, w, h);
planchon_darboux_fill(&mut elev, &mut flow, w, h);
let area = compute_drainage_area(&flow, &elev, w, h);
let lake_id = assign_lake_ids(&elev, &flow, w, h);
let strahler = compute_strahler_order(&flow, &area, &elev, w, h);
let riparian = compute_riparian_distance(&area, &lake_id, w, h);
(flow, area, strahler, lake_id, riparian)
// Full-resolution path — the shared slice solver over the whole map.
let s = run_hydrology_on_slice(&elevation, w, h);
(s.flow, s.area, s.strahler, s.lake_id, s.riparian)
};
// Write results into TileState fields.

View file

@ -0,0 +1,925 @@
//! p2-78 — runtime localized hydrology re-solve.
//!
//! Rivers are baked once at worldgen by [`crate::hydrology::run_hydrology`].
//! This module adds the living-world counterpart: an **event-triggered,
//! bounded** re-run of the same D6 flow + Planchon-Darboux passes around a
//! terraforming obstruction (the `p2-76` bunker damming a river-gap tile),
//! returning only the changed tiles / edges as a [`HydrologyDelta`].
//!
//! ## The differential method (the determinism gate)
//!
//! The hardest correctness property (objective acceptance + risk note): on an
//! **unchanged** window the re-solve must reproduce the global baker's result
//! bit-exact, or every re-solved save diverges. A windowed solve can NOT
//! simply replace baked values: the global Planchon-Darboux fill assigns flow
//! over flat tie-regions via a whole-map priority-flood whose claim order a
//! window cannot replay (the window has no map border to seed from, and ties
//! resolve by global pop order). Instead `resolve_local` runs the windowed
//! solver **twice** — once on the current elevations (*baseline*) and once
//! with the new obstruction applied (*obstructed*) — and emits only the
//! difference, applied **against the baked values**:
//!
//! - no obstruction ⇒ the two runs are identical ⇒ the delta is empty and the
//! grid is reproduced bit-exact, **by construction** (golden-vector test
//! `unchanged_window_yields_empty_delta_bit_exact`);
//! - with a dam ⇒ only dam-attributable changes survive the diff; window-
//! boundary artifacts (slice-border sinks, tie-region claim order) cancel
//! because both runs share them.
//!
//! `drainage_area` and `stream_order` are applied as **signed adjustments**
//! to the baked values (`baked + (obstructed baseline)`), so river
//! contributions entering the window from outside — which the windowed solver
//! cannot see — are preserved. `flow_out` and lake membership are replaced
//! where the two runs differ. `riparian_distance` is recomputed exactly from
//! the final water set over the window plus a `MAX_RIPARIAN_DISTANCE` margin.
//!
//! ## Window construction + boundary rule (documented contract)
//!
//! - The window is the rectangle `[center ± radius]` clipped to the map, with
//! `col0` **aligned down to even parity** so odd-q offset neighbour topology
//! inside the slice matches the global grid.
//! - The slice border acts as a Dirichlet sink: flow exiting the window
//! terminates at the window boundary exactly as map-border flow terminates
//! at the map edge in the global baker (out-of-slice neighbours read as
//! elevation 0; the fill seeds from the slice's border ring). Both the
//! baseline and obstructed runs share this rule, so it cancels in the diff.
//! - **Adaptive expansion:** if any dam-attributable change touches a slice
//! ring tile that is not also a map border tile, the flood/parch footprint
//! may extend past the window — the radius doubles and the solve repeats.
//! The worst case (a dam flooding a basin larger than any window) degrades
//! to a whole-map window, which is still the same delta-only differential
//! solve, never a worldgen re-run.
//! - Changes are clamped to the window: tiles outside it are never written.
//! The one deliberate exception is the reciprocal `river_edges` entry of a
//! neighbour sharing a removed/added river edge with an in-window tile —
//! an edge is one physical feature and must stay symmetric.
//!
//! ## Determinism
//!
//! Pure deterministic geometry: the windowed passes draw **no** RNG (windows
//! are always below the coarse-grid threshold, the only stochastic hydrology
//! path), so no `SeedDomain` is consumed and the PCG64 pin
//! (`WORLDGEN_RNG.md`) is untouched. Same grid + same obstruction ⇒
//! byte-identical delta.
//!
//! ## p2-77 consumability
//!
//! After [`apply_hydrology_delta`], `TileState::flow_out` is the canonical
//! post-dam flow path: `follows_hydrology` contamination walks the
//! `flow_out` pointers downstream exactly as it would on a freshly baked map.
use mc_core::algorithms::hex;
use mc_core::grid::{canonical_edge, reverse_dir, EdgeFeatures, GridState};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use crate::hydrology::{
run_hydrology_on_slice, SliceHydrology, MAX_RIPARIAN_DISTANCE, RIVER_THRESHOLD,
};
// ── Tunables (Rail 2: defaults mirror hydrology.json `local_resolve`) ──────
/// Runtime re-solve tunables. Canonical values live in
/// `public/games/age-of-dwarves/data/hydrology.json` under `local_resolve`;
/// these defaults mirror that block for callers that have not loaded JSON.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct HydrologyResolveParams {
/// Initial half-extent of the re-solve window (hex columns/rows).
pub radius: i32,
/// Absolute elevation the dammed tile is raised to inside the solver's
/// working copy. Elevations are normalised ~[0, 1]; 2.0 tops everything.
pub dam_raise_to: f32,
}
impl Default for HydrologyResolveParams {
fn default() -> Self {
Self { radius: 6, dam_raise_to: 2.0 }
}
}
impl HydrologyResolveParams {
/// Parse from the `hydrology.json` root value (reads the `local_resolve`
/// block); missing fields keep the defaults.
#[must_use]
pub fn from_spec(v: &serde_json::Value) -> Self {
let d = Self::default();
let block = &v["local_resolve"];
Self {
radius: block["radius"].as_i64().map_or(d.radius, |r| r as i32),
dam_raise_to: block["dam_raise_to"]
.as_f64()
.map_or(d.dam_raise_to, |r| r as f32),
}
}
}
// ── Delta types ────────────────────────────────────────────────────────────
/// A terraforming obstruction: the named tile's elevation is raised to
/// `raise_to` inside the solver's working copy. The persistent grid elevation
/// is never mutated — standing obstructions (already-completed bunkers) are
/// re-supplied to later re-solves via `existing_obstructions`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Obstruction {
pub col: i32,
pub row: i32,
pub raise_to: f32,
}
/// New values for one changed tile's five hydrology fields.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TileHydrologyChange {
pub col: i32,
pub row: i32,
pub flow_out: u8,
pub drainage_area: u32,
pub stream_order: u8,
pub lake_id: Option<u32>,
pub riparian_distance: u8,
}
/// One river-edge mutation, addressed from the in-window tile's side.
/// `apply_hydrology_delta` keeps the symmetric invariant: it mirrors the
/// reciprocal direction on the adjacent tile and updates the canonical
/// `edge_features` entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RiverEdgeChange {
pub col: i32,
pub row: i32,
/// Direction index 0..6 from `(col, row)` (AXIAL_DIRECTIONS order).
pub dir: u8,
/// `true` = river added on this edge, `false` = removed.
pub added: bool,
}
/// The changed-tiles/edges-only output of [`resolve_local`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HydrologyDelta {
/// Tiles whose hydrology fields change, row-major order. Values are the
/// final post-apply values.
pub changed_tiles: Vec<TileHydrologyChange>,
/// Tiles newly flooded into a lake `(col, row, lake_id)` — a convenience
/// view of `changed_tiles` entries transitioning `lake_id: None → Some`.
pub added_lake_cells: Vec<(i32, i32, u32)>,
/// River-edge additions/removals (per-tile `river_edges` + canonical
/// `edge_features` mutations).
pub river_edge_changes: Vec<RiverEdgeChange>,
}
impl HydrologyDelta {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changed_tiles.is_empty()
&& self.added_lake_cells.is_empty()
&& self.river_edge_changes.is_empty()
}
}
// ── Window ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
struct Window {
col0: i32,
row0: i32,
lw: i32,
lh: i32,
}
impl Window {
fn around(grid: &GridState, center: (i32, i32), radius: i32) -> Self {
let mut col0 = (center.0 - radius).max(0);
// Parity alignment: odd-q offset neighbour topology depends on column
// parity, so the slice-local column must share the global column's
// parity. col0 is >= 1 when odd, so the decrement stays in bounds.
if col0 % 2 == 1 {
col0 -= 1;
}
let row0 = (center.1 - radius).max(0);
let col1 = (center.0 + radius).min(grid.width - 1);
let row1 = (center.1 + radius).min(grid.height - 1);
Self { col0, row0, lw: col1 - col0 + 1, lh: row1 - row0 + 1 }
}
#[inline]
fn contains(&self, col: i32, row: i32) -> bool {
col >= self.col0 && col < self.col0 + self.lw && row >= self.row0 && row < self.row0 + self.lh
}
#[inline]
fn lidx(&self, col: i32, row: i32) -> usize {
((row - self.row0) * self.lw + (col - self.col0)) as usize
}
fn covers_map(&self, grid: &GridState) -> bool {
self.col0 == 0
&& self.row0 == 0
&& self.lw == grid.width
&& self.lh == grid.height
}
/// Is the local index on the slice's outer ring while NOT being on the
/// real map border? (Map-border ring tiles are legitimate global sinks.)
fn on_interior_ring(&self, grid: &GridState, li: usize) -> bool {
let lcol = (li as i32) % self.lw;
let lrow = (li as i32) / self.lw;
let col = self.col0 + lcol;
let row = self.row0 + lrow;
let on_ring =
lcol == 0 || lcol == self.lw - 1 || lrow == 0 || lrow == self.lh - 1;
let on_map_border =
col == 0 || col == grid.width - 1 || row == 0 || row == grid.height - 1;
on_ring && !on_map_border
}
}
// ── Solver ─────────────────────────────────────────────────────────────────
/// Re-run D6 flow + Planchon-Darboux basin-fill over a bounded window around
/// `center` and return the changed tiles / edges only.
///
/// `existing_obstructions` are standing dams (already applied to the grid's
/// hydrology by earlier re-solves) — they are raised in **both** windowed runs
/// so they keep shaping the baseline. `new_obstruction` is raised in the
/// obstructed run only; pass `None` to verify reproduction of the current
/// state (the empty-delta golden).
///
/// See the module docs for the differential method, the window boundary rule,
/// and the adaptive-expansion contract.
#[must_use]
pub fn resolve_local(
grid: &GridState,
center: (i32, i32),
radius: i32,
existing_obstructions: &[Obstruction],
new_obstruction: Option<&Obstruction>,
) -> HydrologyDelta {
if grid.width <= 0 || grid.height <= 0 {
return HydrologyDelta::default();
}
let mut r = radius.max(1);
loop {
let win = Window::around(grid, center, r);
let (baseline, obstructed) =
solve_window(grid, &win, existing_obstructions, new_obstruction);
// Adaptive expansion: dam-attributable changes touching the slice's
// interior ring mean the footprint may extend past the window.
let full = win.covers_map(grid);
if !full {
let touches_ring = (0..baseline.flow.len()).any(|li| {
differs_at(&baseline, &obstructed, li) && win.on_interior_ring(grid, li)
});
if touches_ring {
r *= 2;
continue;
}
}
return build_delta(grid, &win, &baseline, &obstructed);
}
}
/// Did the two windowed runs differ at local index `li` in any solver field?
#[inline]
fn differs_at(a: &SliceHydrology, b: &SliceHydrology, li: usize) -> bool {
a.flow[li] != b.flow[li]
|| a.area[li] != b.area[li]
|| a.strahler[li] != b.strahler[li]
|| a.lake_id[li].is_some() != b.lake_id[li].is_some()
}
fn solve_window(
grid: &GridState,
win: &Window,
existing: &[Obstruction],
new_obstruction: Option<&Obstruction>,
) -> (SliceHydrology, SliceHydrology) {
let n = (win.lw * win.lh) as usize;
let mut base_elev = Vec::with_capacity(n);
for lrow in 0..win.lh {
for lcol in 0..win.lw {
let gidx = grid.idx(win.col0 + lcol, win.row0 + lrow);
base_elev.push(grid.tiles[gidx].elevation);
}
}
for o in existing {
if win.contains(o.col, o.row) {
let li = win.lidx(o.col, o.row);
base_elev[li] = base_elev[li].max(o.raise_to);
}
}
let baseline = run_hydrology_on_slice(&base_elev, win.lw, win.lh);
let obstructed = match new_obstruction {
Some(o) if win.contains(o.col, o.row) => {
let mut dam_elev = base_elev;
let li = win.lidx(o.col, o.row);
dam_elev[li] = dam_elev[li].max(o.raise_to);
run_hydrology_on_slice(&dam_elev, win.lw, win.lh)
}
_ => baseline.clone(),
};
(baseline, obstructed)
}
// ── Delta construction ─────────────────────────────────────────────────────
fn build_delta(
grid: &GridState,
win: &Window,
baseline: &SliceHydrology,
obstructed: &SliceHydrology,
) -> HydrologyDelta {
let n = baseline.flow.len();
if baseline == obstructed {
return HydrologyDelta::default();
}
// Final per-tile values (row-major local order), diffed against baked.
let mut final_flow = vec![0u8; n];
let mut final_area = vec![0u32; n];
let mut final_order = vec![0u8; n];
let mut final_lake: Vec<Option<u32>> = vec![None; n];
// Lake-component mapping: obstructed-run lake ids are local labels; map
// each component to a global id — the smallest baked id present in the
// component (merge with an existing lake) or a fresh id past the grid's
// current maximum. Components are numbered in deterministic scan order.
let mut fresh_lake_id = grid
.tiles
.iter()
.filter_map(|t| t.lake_id)
.max()
.unwrap_or(0);
let mut local_lake_to_global: std::collections::BTreeMap<u32, u32> =
std::collections::BTreeMap::new();
for li in 0..n {
let Some(local_id) = obstructed.lake_id[li] else { continue };
if local_lake_to_global.contains_key(&local_id) {
continue;
}
// Smallest baked lake_id among this component's tiles, if any.
let baked_in_component = (0..n)
.filter(|&lj| obstructed.lake_id[lj] == Some(local_id))
.filter_map(|lj| {
let lcol = (lj as i32) % win.lw;
let lrow = (lj as i32) / win.lw;
grid.tiles[grid.idx(win.col0 + lcol, win.row0 + lrow)].lake_id
})
.min();
let global = baked_in_component.unwrap_or_else(|| {
fresh_lake_id += 1;
fresh_lake_id
});
local_lake_to_global.insert(local_id, global);
}
for li in 0..n {
let lcol = (li as i32) % win.lw;
let lrow = (li as i32) / win.lw;
let baked = &grid.tiles[grid.idx(win.col0 + lcol, win.row0 + lrow)];
// flow: replacement where the runs differ, baked otherwise.
final_flow[li] = if obstructed.flow[li] != baseline.flow[li] {
obstructed.flow[li]
} else {
baked.flow_out
};
// drainage / order: signed adjustment over baked (preserves river
// contributions entering the window from outside).
let d_area = i64::from(obstructed.area[li]) - i64::from(baseline.area[li]);
final_area[li] = (i64::from(baked.drainage_area) + d_area).clamp(1, i64::from(u32::MAX))
as u32;
let d_order = i32::from(obstructed.strahler[li]) - i32::from(baseline.strahler[li]);
final_order[li] = (i32::from(baked.stream_order) + d_order).clamp(1, 255) as u8;
// lake: a membership CHANGE between the two runs is authoritative
// (newly flooded → mapped global id; drained → None); unchanged
// membership keeps the baked id untouched.
let base_lake = baseline.lake_id[li].is_some();
let obst_lake = obstructed.lake_id[li].is_some();
final_lake[li] = match (base_lake, obst_lake) {
(false, true) => obstructed.lake_id[li]
.and_then(|local| local_lake_to_global.get(&local).copied()),
(true, false) => None, // basin drained by the change
_ => baked.lake_id,
};
}
// Riparian: recompute exactly from the final water set when it changed.
let water_changed = (0..n).any(|li| {
let lcol = (li as i32) % win.lw;
let lrow = (li as i32) / win.lw;
let baked = &grid.tiles[grid.idx(win.col0 + lcol, win.row0 + lrow)];
let baked_water = baked.drainage_area >= RIVER_THRESHOLD || baked.lake_id.is_some();
let final_water = final_area[li] >= RIVER_THRESHOLD || final_lake[li].is_some();
baked_water != final_water
});
let final_riparian: Vec<u8> = if water_changed {
recompute_riparian(grid, win, &final_area, &final_lake)
} else {
(0..n)
.map(|li| {
let lcol = (li as i32) % win.lw;
let lrow = (li as i32) / win.lw;
grid.tiles[grid.idx(win.col0 + lcol, win.row0 + lrow)].riparian_distance
})
.collect()
};
// Assemble changed tiles + river-edge changes, row-major (deterministic).
let mut delta = HydrologyDelta::default();
for li in 0..n {
let lcol = (li as i32) % win.lw;
let lrow = (li as i32) / win.lw;
let col = win.col0 + lcol;
let row = win.row0 + lrow;
let baked = &grid.tiles[grid.idx(col, row)];
let changed = final_flow[li] != baked.flow_out
|| final_area[li] != baked.drainage_area
|| final_order[li] != baked.stream_order
|| final_lake[li] != baked.lake_id
|| final_riparian[li] != baked.riparian_distance;
if changed {
delta.changed_tiles.push(TileHydrologyChange {
col,
row,
flow_out: final_flow[li],
drainage_area: final_area[li],
stream_order: final_order[li],
lake_id: final_lake[li],
riparian_distance: final_riparian[li],
});
if let (None, Some(id)) = (baked.lake_id, final_lake[li]) {
delta.added_lake_cells.push((col, row, id));
}
}
// River-edge semantics. The visible river course (`river_edges`) is
// walker-authored at worldgen and only loosely coupled to drainage, so
// transitions key on the *direction* of the dam-attributable drainage
// change plus the final effective drainage:
// - parch: drainage fell below the river threshold on a tile that
// carries a river and did not flood into a lake → course removed;
// - reroute: drainage rose past the threshold on a dry non-lake tile
// → a new course along the re-solved outflow direction.
let d_area = i64::from(final_area[li]) - i64::from(baked.drainage_area);
let is_lake_now = final_lake[li].is_some();
if d_area < 0
&& final_area[li] < RIVER_THRESHOLD
&& !is_lake_now
&& !baked.river_edges.is_empty()
{
for &dir in &baked.river_edges {
if (0..6).contains(&dir) {
delta.river_edge_changes.push(RiverEdgeChange {
col,
row,
dir: dir as u8,
added: false,
});
}
}
} else if d_area > 0
&& final_area[li] >= RIVER_THRESHOLD
&& baked.drainage_area < RIVER_THRESHOLD
&& !is_lake_now
&& final_flow[li] < 6
&& !baked.river_edges.contains(&i32::from(final_flow[li]))
{
delta.river_edge_changes.push(RiverEdgeChange {
col,
row,
dir: final_flow[li],
added: true,
});
}
}
delta
}
/// Exact riparian recompute over the window + `MAX_RIPARIAN_DISTANCE` margin.
///
/// BFS in global coordinates: water sources inside the window use the final
/// (post-delta) values; sources outside use the baked grid. Any window tile's
/// nearest water within range lies inside the margin (one hex step changes
/// col/row by at most 1), so the bounded BFS reproduces the global BFS
/// exactly for every in-window tile.
fn recompute_riparian(
grid: &GridState,
win: &Window,
final_area: &[u32],
final_lake: &[Option<u32>],
) -> Vec<u8> {
let m = i32::from(MAX_RIPARIAN_DISTANCE);
let ecol0 = (win.col0 - m).max(0);
let erow0 = (win.row0 - m).max(0);
let ecol1 = (win.col0 + win.lw - 1 + m).min(grid.width - 1);
let erow1 = (win.row0 + win.lh - 1 + m).min(grid.height - 1);
let ew = ecol1 - ecol0 + 1;
let eh = erow1 - erow0 + 1;
let eidx = |col: i32, row: i32| ((row - erow0) * ew + (col - ecol0)) as usize;
let is_water = |col: i32, row: i32| -> bool {
if win.contains(col, row) {
let li = win.lidx(col, row);
final_area[li] >= RIVER_THRESHOLD || final_lake[li].is_some()
} else {
let t = &grid.tiles[grid.idx(col, row)];
t.drainage_area >= RIVER_THRESHOLD || t.lake_id.is_some()
}
};
let mut dist = vec![u8::MAX; (ew * eh) as usize];
let mut queue: VecDeque<(i32, i32)> = VecDeque::new();
for row in erow0..=erow1 {
for col in ecol0..=ecol1 {
if is_water(col, row) {
dist[eidx(col, row)] = 0;
queue.push_back((col, row));
}
}
}
while let Some((col, row)) = queue.pop_front() {
let cur = dist[eidx(col, row)];
if cur >= MAX_RIPARIAN_DISTANCE {
continue;
}
let (q, r) = hex::offset_to_axial(col, row);
for &(dq, dr) in &hex::AXIAL_DIRECTIONS {
let (ncol, nrow) = hex::axial_to_offset(q + dq, r + dr);
if ncol < ecol0 || ncol > ecol1 || nrow < erow0 || nrow > erow1 {
continue;
}
let ni = eidx(ncol, nrow);
if dist[ni] == u8::MAX {
dist[ni] = cur + 1;
queue.push_back((ncol, nrow));
}
}
}
let mut out = Vec::with_capacity((win.lw * win.lh) as usize);
for lrow in 0..win.lh {
for lcol in 0..win.lw {
out.push(dist[eidx(win.col0 + lcol, win.row0 + lrow)]);
}
}
out
}
// ── Apply ──────────────────────────────────────────────────────────────────
/// Write a [`HydrologyDelta`] into the grid: the five hydrology fields on
/// each changed tile, plus symmetric `river_edges` mutations mirrored onto
/// the adjacent tile and the canonical `edge_features` map (sparse: an entry
/// reduced to `EdgeFeatures::default()` is removed).
pub fn apply_hydrology_delta(grid: &mut GridState, delta: &HydrologyDelta) {
for ch in &delta.changed_tiles {
if let Some(tile) = grid.tile_mut(ch.col, ch.row) {
tile.flow_out = ch.flow_out;
tile.drainage_area = ch.drainage_area;
tile.stream_order = ch.stream_order;
tile.lake_id = ch.lake_id;
tile.riparian_distance = ch.riparian_distance;
}
}
for ec in &delta.river_edge_changes {
let d = i32::from(ec.dir);
if let Some(tile) = grid.tile_mut(ec.col, ec.row) {
if ec.added {
if !tile.river_edges.contains(&d) {
tile.river_edges.push(d);
}
} else {
tile.river_edges.retain(|&x| x != d);
}
}
// Reciprocal entry on the adjacent tile (edges are one physical
// feature; symmetry must hold even across the window boundary).
let (q, r) = hex::offset_to_axial(ec.col, ec.row);
let (dq, dr) = hex::AXIAL_DIRECTIONS[ec.dir as usize];
let (ncol, nrow) = hex::axial_to_offset(q + dq, r + dr);
let rd = i32::from(reverse_dir(ec.dir));
if let Some(nt) = grid.tile_mut(ncol, nrow) {
if ec.added {
if !nt.river_edges.contains(&rd) {
nt.river_edges.push(rd);
}
} else {
nt.river_edges.retain(|&x| x != rd);
}
}
// Canonical edge_features entry.
let edge_id = canonical_edge((q, r), ec.dir);
if ec.added {
grid.edge_features.entry(edge_id).or_default().river = true;
} else if let Some(f) = grid.edge_features.get_mut(&edge_id) {
f.river = false;
if *f == EdgeFeatures::default() {
grid.edge_features.remove(&edge_id);
}
}
}
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::hydrology::run_hydrology;
use mc_core::grid::GridState;
fn gradient_grid(w: i32, h: i32) -> GridState {
let mut g = GridState::new(w, h);
for t in &mut g.tiles {
t.elevation = 1.0 - (t.col + t.row) as f32 / (w + h) as f32;
}
g
}
/// Synthetic dammed-valley fixture (shared shape with the worldsim test
/// and the `hydrology_dam_proof` scene):
///
/// - 24×16 map, high walls (~0.9 with a tiny deterministic eastward slope
/// so no flat tie-plateau forms);
/// - main channel along row 8, cols 10..=23, descending eastward from
/// 0.50 to 0.24 and draining off the east map border;
/// - side spillway at col 12, rows 0..=7, descending northward from 0.55
/// at (12,7) — the contained spill route once the channel is dammed;
/// - the dam site is `(DAM_COL, 8)`, a mid-channel river-gap tile.
const VALLEY_W: i32 = 24;
const VALLEY_H: i32 = 16;
const CHANNEL_ROW: i32 = 8;
const DAM_COL: i32 = 14;
fn valley_grid() -> GridState {
let mut g = GridState::new(VALLEY_W, VALLEY_H);
for t in &mut g.tiles {
t.elevation = 0.9 - 0.002 * t.col as f32 - 0.001 * t.row as f32;
}
for col in 10..VALLEY_W {
let i = g.idx(col, CHANNEL_ROW);
g.tiles[i].elevation = 0.50 - 0.02 * (col - 10) as f32;
}
for row in 0..=7 {
let i = g.idx(12, row);
g.tiles[i].elevation = 0.55 - 0.01 * (7 - row) as f32;
}
run_hydrology(0, &mut g);
// Visible river course on the channel (walker-authored shape: marked
// symmetrically on both adjacent tiles, here via the delta-apply
// helper semantics — E/W on every channel tile).
for col in 10..VALLEY_W {
let i = g.idx(col, CHANNEL_ROW);
g.tiles[i].river_edges = vec![0, 3];
}
g.migrate_river_edges_to_edge_features();
g
}
fn dam_obstruction() -> Obstruction {
Obstruction { col: DAM_COL, row: CHANNEL_ROW, raise_to: 2.0 }
}
fn grid_json(g: &GridState) -> String {
serde_json::to_string(g).expect("serialize grid")
}
// ── THE determinism gate (written first; shapes the extraction) ────────
/// Golden regression vector: on an UNCHANGED window the local re-solve
/// must reproduce the global baker's result bit-exact — empty delta, and
/// applying it leaves the serialized grid byte-identical. Covers multiple
/// seeds, centers, radii, and both synthetic shapes.
#[test]
fn unchanged_window_yields_empty_delta_bit_exact() {
let mut grids = vec![valley_grid()];
for &seed in &[0u64, 42, 0xDEAD_BEEF] {
let mut g = gradient_grid(30, 20);
run_hydrology(seed, &mut g);
grids.push(g);
}
for mut g in grids {
let before = grid_json(&g);
for &center in &[(5, 5), (14, 8), (0, 0), (23, 14)] {
for &radius in &[3, 6, 99] {
let delta = resolve_local(&g, center, radius, &[], None);
assert!(
delta.is_empty(),
"unchanged window must produce an empty delta \
(center={center:?} radius={radius}): {delta:?}"
);
apply_hydrology_delta(&mut g, &delta);
}
}
assert_eq!(
grid_json(&g),
before,
"grid must be byte-identical after no-op re-solves"
);
}
}
/// Same grid + same obstruction ⇒ byte-identical delta (no RNG, no
/// SeedDomain draw — deterministic geometry only).
#[test]
fn dam_resolve_is_deterministic() {
let g1 = valley_grid();
let g2 = valley_grid();
let o = dam_obstruction();
let d1 = resolve_local(&g1, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
let d2 = resolve_local(&g2, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
assert!(!d1.is_empty(), "dam must produce a non-empty delta");
assert_eq!(
serde_json::to_string(&d1).unwrap(),
serde_json::to_string(&d2).unwrap(),
"same seed + same terraforming act must produce an identical delta"
);
}
// ── Dam behaviour ───────────────────────────────────────────────────────
/// The headline behaviour: upstream-of-dam floods into a lake; downstream
/// loses its river course and riparian_distance rises.
#[test]
fn dam_floods_upstream_and_parches_downstream() {
let mut g = valley_grid();
let pre_downstream_riparian = g.tiles[g.idx(16, CHANNEL_ROW)].riparian_distance;
assert_eq!(pre_downstream_riparian, 0, "channel tile starts on water");
let o = dam_obstruction();
let delta = resolve_local(&g, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
apply_hydrology_delta(&mut g, &delta);
// Upstream floods: the channel tiles west of the dam join a new lake.
assert!(!delta.added_lake_cells.is_empty(), "dam must create lake cells");
let upstream_flooded = delta
.added_lake_cells
.iter()
.any(|&(c, r, _)| r == CHANNEL_ROW && c < DAM_COL);
assert!(upstream_flooded, "upstream channel tiles must flood: {delta:?}");
let lake_tile = delta.added_lake_cells[0];
assert!(
g.tiles[g.idx(lake_tile.0, lake_tile.1)].lake_id.is_some(),
"applied lake cell must carry a lake_id"
);
// Downstream parches: river edges removed just past the dam…
let removed: Vec<&RiverEdgeChange> = delta
.river_edge_changes
.iter()
.filter(|e| !e.added)
.collect();
assert!(
removed.iter().any(|e| e.row == CHANNEL_ROW && e.col > DAM_COL),
"downstream river edges must be removed: {delta:?}"
);
let parched = removed
.iter()
.map(|e| (e.col, e.row))
.find(|&(c, r)| r == CHANNEL_ROW && c > DAM_COL)
.unwrap();
assert!(
g.tiles[g.idx(parched.0, parched.1)].river_edges.is_empty(),
"parched tile must carry no river_edges after apply"
);
// …and riparian_distance rises off the dead course.
assert!(
g.tiles[g.idx(parched.0, parched.1)].riparian_distance > 0,
"parched tile must no longer be riparian-0"
);
// Drainage through the dam tile itself collapses.
assert!(
delta
.changed_tiles
.iter()
.any(|t| (t.col, t.row) == (DAM_COL, CHANNEL_ROW)),
"dam tile must be in the changed set"
);
}
/// The localized contract: every changed tile / edge / lake cell lies
/// inside the final window, and tiles outside it are byte-identical
/// after apply (modulo the documented reciprocal-edge exception, which
/// this fixture's removals keep inside the window anyway).
#[test]
fn resolve_is_localized_outside_tiles_untouched() {
let mut g = valley_grid();
let snapshot: Vec<String> = g
.tiles
.iter()
.map(|t| serde_json::to_string(t).unwrap())
.collect();
let o = dam_obstruction();
let delta = resolve_local(&g, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
// Bound every delta coordinate (window may have expanded; it is still
// bounded by the map — assert the change set is sparse, not global).
let changed: std::collections::BTreeSet<(i32, i32)> = delta
.changed_tiles
.iter()
.map(|t| (t.col, t.row))
.collect();
assert!(
changed.len() < (VALLEY_W * VALLEY_H) as usize / 2,
"dam delta must stay sparse, got {} changed tiles",
changed.len()
);
apply_hydrology_delta(&mut g, &delta);
let touched: std::collections::BTreeSet<(i32, i32)> = delta
.changed_tiles
.iter()
.map(|t| (t.col, t.row))
.chain(delta.river_edge_changes.iter().flat_map(|e| {
let (q, r) = hex::offset_to_axial(e.col, e.row);
let (dq, dr) = hex::AXIAL_DIRECTIONS[e.dir as usize];
let n = hex::axial_to_offset(q + dq, r + dr);
[(e.col, e.row), n]
}))
.collect();
for (i, t) in g.tiles.iter().enumerate() {
if touched.contains(&(t.col, t.row)) {
continue;
}
assert_eq!(
serde_json::to_string(t).unwrap(),
snapshot[i],
"untouched tile ({}, {}) must be byte-identical",
t.col,
t.row
);
}
}
/// A standing dam supplied as an existing obstruction shapes BOTH runs, so
/// re-resolving the same dam as existing-only is a no-op (idempotence —
/// the compounding-dams contract).
#[test]
fn existing_obstruction_in_baseline_is_idempotent() {
let mut g = valley_grid();
let o = dam_obstruction();
let delta = resolve_local(&g, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
apply_hydrology_delta(&mut g, &delta);
let again = resolve_local(&g, (DAM_COL, CHANNEL_ROW), 6, &[o], None);
assert!(
again.is_empty(),
"re-resolving a standing dam as existing-only must be a no-op: {again:?}"
);
}
/// p2-77 consumability: after the dam, walking `flow_out` pointers from an
/// upstream tile never crosses the dam tile — the contamination
/// `follows_hydrology` path reads the re-solved field directly.
#[test]
fn flow_path_after_dam_avoids_dam_tile() {
let mut g = valley_grid();
let o = dam_obstruction();
let delta = resolve_local(&g, (DAM_COL, CHANNEL_ROW), 6, &[], Some(&o));
apply_hydrology_delta(&mut g, &delta);
let (mut col, mut row) = (11, CHANNEL_ROW); // upstream of the dam
for _ in 0..((VALLEY_W * VALLEY_H) as usize) {
assert_ne!(
(col, row),
(DAM_COL, CHANNEL_ROW),
"post-dam flow path must not pass through the dam tile"
);
let t = &g.tiles[g.idx(col, row)];
if t.flow_out >= 6 {
break; // sink
}
let (q, r) = hex::offset_to_axial(col, row);
let (dq, dr) = hex::AXIAL_DIRECTIONS[t.flow_out as usize];
let (nc, nr) = hex::axial_to_offset(q + dq, r + dr);
if nc < 0 || nc >= g.width || nr < 0 || nr >= g.height {
break; // drains off-map
}
col = nc;
row = nr;
}
}
/// Empty grid does not panic and yields an empty delta.
#[test]
fn empty_grid_yields_empty_delta() {
let g = GridState::new(0, 0);
let o = Obstruction { col: 0, row: 0, raise_to: 2.0 };
assert!(resolve_local(&g, (0, 0), 6, &[], Some(&o)).is_empty());
}
/// `HydrologyResolveParams` reads the `local_resolve` block and falls back
/// to defaults that mirror `hydrology.json`.
#[test]
fn resolve_params_from_spec() {
let v: serde_json::Value =
serde_json::from_str(r#"{"local_resolve":{"radius":9,"dam_raise_to":1.5}}"#).unwrap();
let p = HydrologyResolveParams::from_spec(&v);
assert_eq!(p.radius, 9);
assert!((p.dam_raise_to - 1.5).abs() < f32::EPSILON);
let d = HydrologyResolveParams::from_spec(&serde_json::json!({}));
assert_eq!(d, HydrologyResolveParams::default());
}
}

View file

@ -30,6 +30,12 @@ pub use erosion::{run_erosion, ErosionParams};
pub mod hydrology;
pub use hydrology::{run_hydrology, RIVER_THRESHOLD, MAX_RIPARIAN_DISTANCE};
pub mod hydrology_resolve;
pub use hydrology_resolve::{
apply_hydrology_delta, resolve_local, HydrologyDelta, HydrologyResolveParams, Obstruction,
RiverEdgeChange, TileHydrologyChange,
};
pub mod spawn_box;
pub use spawn_box::{place_spawn_box, SpawnBox, SpawnBoxParams, SPAWN_BOX_STREAM_TAG};

View file

@ -727,23 +727,6 @@ 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

@ -327,17 +327,6 @@ fn contamination_duration_scales_with_destroyed_tier() {
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();