From 31f88a2e9551d7ad5940659e4741a87d84ca4755 Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 10 Jun 2026 04:20:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects):=20=E2=9C=A8=20document=20paral?= =?UTF-8?q?lel=20simulation=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../p2-83-phase-round-state-machine-design.md | 640 ++++++++++++ .../games/age-of-dwarves/data/hydrology.json | 4 + .../crates/mc-mapgen/src/hydrology.rs | 50 +- .../crates/mc-mapgen/src/hydrology_resolve.rs | 925 ++++++++++++++++++ src/simulator/crates/mc-mapgen/src/lib.rs | 6 + .../crates/mc-state/src/game_state.rs | 17 - .../crates/mc-turn/src/improvement_tests.rs | 11 - 7 files changed, 1616 insertions(+), 37 deletions(-) create mode 100644 .project/designs/p2-83-phase-round-state-machine-design.md create mode 100644 src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs diff --git a/.project/designs/p2-83-phase-round-state-machine-design.md b/.project/designs/p2-83-phase-round-state-machine-design.md new file mode 100644 index 00000000..d6e34cbe --- /dev/null +++ b/.project/designs/p2-83-phase-round-state-machine-design.md @@ -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 2–4b — 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` 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, + +/// 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, +} + +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; + /// Close the active player's round (the EndTurn entry point). + pub fn end_player_round(&mut self, state: &mut GameState) -> Vec; +} +``` + +**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` 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 (6–8 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 2–4b 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; + S1–S3 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`, 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.* diff --git a/public/games/age-of-dwarves/data/hydrology.json b/public/games/age-of-dwarves/data/hydrology.json index 78fdf2ee..3aa3d9fb 100644 --- a/public/games/age-of-dwarves/data/hydrology.json +++ b/public/games/age-of-dwarves/data/hydrology.json @@ -12,5 +12,9 @@ "river_threshold": 12, "max_riparian_distance": 5, "coarse_grid_threshold": 150 + }, + "local_resolve": { + "radius": 6, + "dam_raise_to": 2.0 } } diff --git a/src/simulator/crates/mc-mapgen/src/hydrology.rs b/src/simulator/crates/mc-mapgen/src/hydrology.rs index e6db7cb4..1af4cad5 100644 --- a/src/simulator/crates/mc-mapgen/src/hydrology.rs +++ b/src/simulator/crates/mc-mapgen/src/hydrology.rs @@ -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, + pub area: Vec, + pub strahler: Vec, + pub lake_id: Vec>, + pub riparian: Vec, +} + +/// 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. diff --git a/src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs b/src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs new file mode 100644 index 00000000..020ba4bc --- /dev/null +++ b/src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs @@ -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, + 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, + /// 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, +} + +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> = 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 = + 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 = 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], +) -> Vec { + 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 ¢er 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 = 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()); + } +} diff --git a/src/simulator/crates/mc-mapgen/src/lib.rs b/src/simulator/crates/mc-mapgen/src/lib.rs index e99613c2..7712b119 100644 --- a/src/simulator/crates/mc-mapgen/src/lib.rs +++ b/src/simulator/crates/mc-mapgen/src/lib.rs @@ -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}; diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index e8211de7..2c5615ee 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -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)); diff --git a/src/simulator/crates/mc-turn/src/improvement_tests.rs b/src/simulator/crates/mc-turn/src/improvement_tests.rs index bc930a55..f540bc02 100644 --- a/src/simulator/crates/mc-turn/src/improvement_tests.rs +++ b/src/simulator/crates/mc-turn/src/improvement_tests.rs @@ -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();