feat(@projects): ✨ document parallel simulation design
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3ebe54f387
commit
31f88a2e95
7 changed files with 1616 additions and 37 deletions
640
.project/designs/p2-83-phase-round-state-machine-design.md
Normal file
640
.project/designs/p2-83-phase-round-state-machine-design.md
Normal 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 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<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 (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<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.*
|
||||
|
|
@ -12,5 +12,9 @@
|
|||
"river_threshold": 12,
|
||||
"max_riparian_distance": 5,
|
||||
"coarse_grid_threshold": 150
|
||||
},
|
||||
"local_resolve": {
|
||||
"radius": 6,
|
||||
"dam_raise_to": 2.0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
925
src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs
Normal file
925
src/simulator/crates/mc-mapgen/src/hydrology_resolve.rs
Normal 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 ¢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<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue