Merge remote-tracking branch 'origin/t1185-outlet-wiring'

This commit is contained in:
2026-07-25 10:30:24 +02:00
5 changed files with 1901 additions and 4 deletions
+1
View File
@@ -7,6 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- **Lakes now show whether they drain** (D-227, T-1185) — every overflow lake's exit river is wired into the served course network: where a basin fills past its spill point, the water's escape path appears as a real river continuation (most commonly a short spill onto adjacent open ground — the honest dominant pattern; a long dramatic exit river to the sea is the exception). A closed (endorheic) basin gains nothing — the absence of any outgoing course IS the signal, exactly as ruled: no extra data bit, just hydrology you can read off the map
- **Step-canvas serving — the map's new data pipe** (D-255, T-1181) — the server now answers "what is at this world coordinate at this zoom step" as a single tagged message per step: all six Atlas ladder rungs (the whole-body Global opener down to 64 m Chunk) served through one StepCanvasRequest/StepCanvasResponse envelope, with dense terrain fields shipped as compact per-field PNGs and river courses/cliffs as native lists (the encoding the workshop measured smallest-and-fastest). Backed by a keep-always whole-body tier (~9 MB for all ~267 bodies) plus a per-rung cache with honest freshness rules (terrain never goes stale — only storage evicts it; seasonal/tidal planes carry real clocks). Byte-identical cache-hit-vs-miss is enforced by a dedicated acceptance test on every rung — the guarantee that a player walking somewhere without ever opening the map gets the identical world. River detail at deep zoom is capped at the density that already reads well one rung up, eliminating a measured 38-87% derive-cost penalty with no visible loss. Server side only — the client map component that draws these canvases is the next ticket (T-1182)
- **Lakes from settled hydrology** (D-227, T-1184) — depressions in a body's terrain now hold water: an equilibrium hydrology solve (the T-1177 solver, ~24 ms per body) runs once per body and fills every basin to its settled water level, and the map's terrain classification reads it directly — a spot is a lake when the settled water surface sits above the ground there. Lake edges refine with zoom exactly like coastlines (continuous surface comparison, never a blocky cell lookup), a lake's existence never flickers (static classification, distinct from tidal/seasonal flooding), and bodies without a solve fall back to the old ocean-fraction heuristic byte-identically. Whether a lake drains via an exit river or sits closed (endorheic) becomes readable when the outlet wiring lands (T-1185)
- **3D locomotion sandbox + in-house wardrobe engine — Fable-5 character sidequest** (D-248D-252, T-1088, T-1089) — a sanctioned cascade exception delivered a walking 3D character ahead of Phase 5: a locomotion sandbox (SR_LIVE, greybox tiles derived from live server snapshots, no hand-built map) with per-leg constant-velocity interpolation cadence-synced to gait clips, mouse-driven move-here pathing with a walk/sprint/take-cover gesture vocabulary, and a client-side wall-cutaway camera kept decoupled from fog-of-perception. The server protocol's `Facing` is now view-only — movement direction is derived client-side from position deltas, and NPC gaze relocates to path-follow intent (D-252). Alongside it, a full in-house wardrobe engine: offset-shell garment authoring from our own body meshes (per-body mode, weights inherited by construction), a 24-garment catalogue — everyday casual through a hand-authored suit and colorable uniform — fitted across all 11 body types, RGBA multi-region tinting with per-character brand logos, a creation-screen try-on UI, 12 named outfit presets, and a chromakey QA harness gating every garment for clip-through before release. The character asset route (Quaternius rig, in-house wardrobe) was re-confirmed after a hands-on vendor evaluation (D-251 — Synty proved technically viable but was rejected on cost, modularity, and style), and the purchased UAL1/UAL2 animation tiers are wired in. Five of the eleven body types, found broken bare mid-sidequest (T-1090), were repaired — all eleven now render and fit correctly
+706
View File
@@ -397,6 +397,304 @@ pub fn solve(
}
}
// ---------------------------------------------------------------------------
// Basin-outlet → D8 river-network wiring (T-1185, D-227 amendment (4))
// ---------------------------------------------------------------------------
/// Extend a D8-derived [`RiverNetwork`] with **outlet continuations** for
/// every [`BasinOutcome::Overflow`] basin in `result` — the wiring the D-227
/// amendment (4) endorheic cue rests on ("outflow-course PRESENCE, no wire
/// bit, no 18th zone"). Additive per T-1170 Ruling 7b: `Endorheic` basins
/// contribute nothing (their absence of a continuation IS the cue, already
/// read correctly by every existing consumer — [`crate::atlas::river_course::
/// build_edges`] already no-ops on cells it never sees), and every appended
/// cell is a plain new [`RiverNetwork::river_cells`] entry with a real D8
/// [`RiverNetwork::river_downstream`] pointer, indistinguishable downstream
/// from a naturally-extracted river cell — no new wire field, no new sentinel.
///
/// **Edge identity (D-010 determinism).** Each new river cell's identity is
/// its own `(row, col)` position (the same convention every existing river
/// cell already uses — [`crate::atlas::river_course::pack_cell_id`] derives
/// `edge_id` from the upstream cell's packed position) — never an insertion
/// index or iteration order. Basins are walked in ascending `basin_id` order
/// (itself a deterministic row-major discovery order, [`label_lake_basins`]),
/// and `outlet_path` is walked in its own fixed emission order (a Dijkstra
/// `came`-chain reconstruction, deterministic per basin) — so two independent
/// solves of the same input produce byte-identical appended cells in
/// byte-identical order. No hashing, no `HashMap`, nothing keyed on wall-clock
/// or thread scheduling.
///
/// **Cell selection — the spill cell ALWAYS gets a real outflow pointer
/// (PR #202 review, Hoshe finding 1).** `outlet_path` is "inclusive of both
/// ends": index 0 is the basin's `spill_cell` (a `Lake` gridunit at the
/// basin's own boundary — morphology sourcing and river-cell membership are
/// independent, T-1184, so a cell can legitimately be both), index
/// `len - 1` is the downstream terminus. The spill cell is the ONE cell that
/// visually anchors the lake's exit: every course drawn on the map starts at
/// a `RiverEdge::upstream` position ([`crate::atlas::river_course::
/// invent_course`]'s `anchor_a`), so unless the spill cell itself carries an
/// outflow-pointing entry, NOTHING in the served network visually touches
/// this lake — a real `Overflow` basin would read exactly like `Endorheic`
/// (no course leaving the lake), which is the CUE this ticket exists to
/// deliver, not an incidental gap. This is why the spill cell is never
/// skipped, unconditionally: it always gets an entry pointing along
/// `outlet_path` (D8 direction toward `outlet_path[1]`, or straight to the
/// terminus sentinel on a length-1 residual path — see below), whether or
/// not it was already a `river_cells` member. If it WAS already present (a
/// real, if rare, case — verified on GJ1c: 2/51 real `Overflow` basins have
/// a pre-existing-river-cell spill point), its EXISTING entry is
/// overwritten in place (same array index, same position, new downstream
/// direction/class/seaward) rather than appended — appending would create a
/// second `river_cells` entry at the same `(row, col)`, which
/// [`crate::atlas::river_course::build_edges`] would turn into two
/// `RiverEdge`s sharing the same `edge_id` (`pack_cell_id` is a pure
/// function of position, Ruling 2d), corrupting the
/// one-edge-per-upstream-cell invariant `edge_ids_are_unique` guards.
/// Overwriting is correct, not merely safe: the pre-existing pointer was
/// computed by `extract_river_network`'s D8 walk on the ORIGINAL
/// (unfilled) surface, which has no knowledge of the basin's spill
/// direction — the hydrology solve is the more authoritative answer for
/// what water actually does at this specific cell once the basin is full,
/// so it wins.
///
/// Every remaining interior path cell (index 1 through `len - 2`) becomes a
/// new river cell whose downstream pointer is the real D8 direction toward
/// the NEXT path cell (mirroring `extract_river_network`'s own `fdir[i]`
/// convention exactly — `outlet_path` is itself a chain of true D8
/// neighbors, guaranteed by construction: [`cheapest_overflow_path`]'s
/// Dijkstra only ever relaxes D8-adjacent cells). The final path cell's
/// downstream pointer is set from `downstream_target`: [`DownstreamTarget::
/// Sea`] maps to [`RIVER_DOWNSTREAM_MOUTH`] (a real river reaching the sea
/// IS a mouth, the same semantics `extract_river_network` already assigns),
/// and [`DownstreamTarget::Basin`]/[`DownstreamTarget::OpenSpillway`] both
/// map to [`RIVER_DOWNSTREAM_EDGE_DRAIN`] (the course simply ends at the
/// last station — there is no meaningful "direction" once the outlet has
/// reached another lake's footprint or open ground, the same semantics
/// `build_edges` already gives a grid-artifact edge-drain: no further chord
/// to invent).
///
/// **Interior cells (index ≥ 1) already present in `river_cells` STOP the
/// walk** (an outlet path can legitimately re-enter the D8-extracted
/// network, e.g. `DownstreamTarget::Basin` chaining through a stretch of
/// terrain the original extraction already classified as a river) — this is
/// safe (unlike the spill-cell case above) precisely BECAUSE the spill cell
/// always got its own real entry first: the chain from the lake is never
/// silently dropped, only its LATER re-entry into pre-existing machinery is
/// deduplicated. The cell immediately before the collision (which may BE the
/// spill cell itself, on a length-2 path) correctly points its D8 direction
/// at the existing river cell, which already has its own onward pointer —
/// the course continues through machinery that was already there.
///
/// **`EdgeUnreachable` paths and a length-1 residual (spill-only) path still
/// get the spill cell's real entry, just with no interior D8 hop.** A path
/// of length ≤ 1 (`outlet_path == [spill]` — the `EdgeUnreachable`
/// best-effort case, or any basin whose search terminates immediately) has
/// no `outlet_path[1]` to point toward, so the spill cell's downstream
/// sentinel is taken directly from `downstream_target` (the same terminus
/// mapping the multi-cell path's LAST cell uses) instead of a D8 direction —
/// still a real, readable outflow entry, never silently dropped.
///
/// **`river_class` scope note.** Every appended cell is classified `0`
/// (stream — [`crate::atlas::river_course`]'s narrowest, most conservative
/// meander-amplitude multiplier), a fixed default rather than a value
/// derived from the outlet channel's own flow accumulation. This is a
/// deliberate scope boundary, not an oversight: this ticket wires PRESENCE
/// (does a course leave this lake, yes/no — the D-227 amendment (4) cue),
/// not a believability pass on outlet-channel *classification*. A basin's
/// outlet plausibly deserves a wider/trunk-scaled meander (it concentrates
/// the whole basin's catchment through one channel) — that is a real,
/// separate refinement, deferred rather than guessed at here.
pub fn extend_river_network_with_basin_outlets(
result: &HydrologyResult,
width: u32,
height: u32,
mut network: crate::atlas::body_world_state::RiverNetwork,
) -> crate::atlas::body_world_state::RiverNetwork {
use crate::atlas::body_world_state::RIVER_DOWNSTREAM_MOUTH;
let w = width as usize;
let n = (width as usize) * (height as usize);
// Position -> array-index for the network's pre-existing river cells — a
// dense `Vec` (same shape/determinism story as `is_lake`/`basin_of`
// above: O(1) point lookup, never iterated, so there is no D-010
// HashMap-iteration-order concern to even raise). Built once, outside
// the basin loop (basins can number in the thousands on a real body, per
// the T-1177 population survey: 22,270 basins across 267 bodies).
// Doubles as the membership test the interior-cell dedup walk needs; the
// INDEX half is what the spill-cell overwrite case (Hoshe finding 1)
// needs to mutate the correct existing entry in place rather than
// appending a duplicate.
let mut river_cell_index: Vec<Option<usize>> = vec![None; n];
for (idx, &(r, c)) in network.river_cells.iter().enumerate() {
river_cell_index[r as usize * w + c as usize] = Some(idx);
}
// Basins are already stored in ascending `basin_id` order (the `(0..
// basin_count).map(...)` construction above) — iterate as-is rather than
// re-sorting, preserving the deterministic row-major discovery order
// `label_lake_basins` assigned.
for basin in &result.basins {
let BasinOutcome::Overflow {
outlet_path,
downstream_target,
} = &basin.outcome
else {
continue; // Endorheic — no continuation; absence IS the cue.
};
if outlet_path.is_empty() {
continue; // Degenerate — no spill cell at all to anchor from.
}
// The spill cell's downstream sentinel (fn doc's "Cell selection"
// section): real D8 direction toward `outlet_path[1]` when an
// interior cell exists, otherwise the terminus mapping directly (a
// length-1 residual/EdgeUnreachable path) — either way, ALWAYS a
// real entry, never skipped.
let spill = outlet_path[0];
let (sr, sc) = (spill / w, spill % w);
let spill_sentinel = if outlet_path.len() >= 2 {
let next = outlet_path[1];
let (nr, nc) = (next / w, next % w);
d8_direction_between((sr, sc), (nr, nc), w)
} else {
terminus_sentinel(downstream_target)
};
let spill_seaward = seaward_for(spill_sentinel, sr, sc, RIVER_DOWNSTREAM_MOUTH);
match river_cell_index[spill] {
Some(existing_idx) => {
// Overwrite in place (fn doc: the hydrology solve is the
// more authoritative answer for this cell's true downstream
// direction than the original flat D8 extraction) — never
// append, which would duplicate `edge_id` at this position.
network.river_class[existing_idx] = 0;
network.river_downstream[existing_idx] = spill_sentinel;
network.river_seaward[existing_idx] = spill_seaward;
}
None => {
let new_idx = network.river_cells.len();
network.river_cells.push((sr as u16, sc as u16));
network.river_class.push(0);
network.river_downstream.push(spill_sentinel);
network.river_seaward.push(spill_seaward);
river_cell_index[spill] = Some(new_idx);
}
}
if outlet_path.len() < 2 {
continue; // No interior cell beyond the spill entry above.
}
for i in 1..outlet_path.len() {
let cell = outlet_path[i];
let (r, c) = (cell / w, cell % w);
if river_cell_index[cell].is_some() {
// Re-entered the pre-existing D8 network (e.g. chained into
// another basin's already-extracted river reach) — the prior
// cell's downstream pointer (the spill entry above, or a
// PREVIOUS loop iteration) already points here, so the chain
// is visually continuous without adding a duplicate entry.
// Safe here (unlike the spill cell) because the spill entry
// above already guarantees the lake's own outflow is never
// silently dropped — this only dedups a LATER re-entry.
break;
}
// Downstream pointer: real D8 direction toward the NEXT path
// cell, or the terminus sentinel at the path's own end.
let sentinel = if i + 1 < outlet_path.len() {
let next = outlet_path[i + 1];
let (nr, nc) = (next / w, next % w);
d8_direction_between((r, c), (nr, nc), w)
} else {
terminus_sentinel(downstream_target)
};
let new_idx = network.river_cells.len();
network.river_cells.push((r as u16, c as u16));
network.river_class.push(0); // stream — fixed default, see fn doc's scope note
network.river_downstream.push(sentinel);
network
.river_seaward
.push(seaward_for(sentinel, r, c, RIVER_DOWNSTREAM_MOUTH));
river_cell_index[cell] = Some(new_idx);
}
}
network
}
/// Terminus sentinel for a [`BasinOutcome::Overflow`]'s downstream end, from
/// [`DownstreamTarget`] — shared by both the spill-cell entry (a length-1
/// residual path with no interior cell to point at) and the last interior
/// path cell's entry (the common multi-cell case).
/// [`DownstreamTarget::Sea`] maps to [`RIVER_DOWNSTREAM_MOUTH`] (a real
/// river reaching the sea IS a mouth, the same semantics
/// `extract_river_network` already assigns); every other variant
/// ([`DownstreamTarget::Basin`], [`DownstreamTarget::OpenSpillway`],
/// [`DownstreamTarget::EdgeUnreachable`]) maps to
/// [`RIVER_DOWNSTREAM_EDGE_DRAIN`] — no further chord to invent once the
/// outlet has reached another lake's footprint, open ground, or exhausted
/// its search budget.
fn terminus_sentinel(target: &DownstreamTarget) -> u8 {
use crate::atlas::body_world_state::{RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH};
match target {
DownstreamTarget::Sea => RIVER_DOWNSTREAM_MOUTH,
DownstreamTarget::Basin(_)
| DownstreamTarget::OpenSpillway
| DownstreamTarget::EdgeUnreachable => RIVER_DOWNSTREAM_EDGE_DRAIN,
}
}
/// [`RiverNetwork::river_seaward`] value for a cell whose downstream
/// sentinel is `sentinel` — a real seaward neighbor when `sentinel ==
/// mouth_sentinel` (see [`extend_river_network_with_basin_outlets`]'s doc on
/// why `(r, c)` itself, not a cell beyond it, is the correct seaward value
/// here — mirrors `extract_river_network`'s own `(nr, nc)` capture), the
/// unreadable `(0, 0)` placeholder otherwise (matches every non-MOUTH
/// `river_seaward` entry across the codebase).
fn seaward_for(sentinel: u8, r: usize, c: usize, mouth_sentinel: u8) -> (u16, u16) {
if sentinel == mouth_sentinel {
(r as u16, c as u16)
} else {
(0, 0)
}
}
/// The D8 direction index `k` (matching [`d8_offset`]'s table) such that
/// stepping from `(r, c)` by `d8_offset(k)` (with horizontal wraparound,
/// [`crate::atlas::drainage`]'s convention) reaches `(nr, nc)`. Panics if the
/// two cells are not true D8 neighbors — a programmer error (every caller
/// derives `(nr, nc)` from a Dijkstra path that only ever relaxes true D8
/// neighbors, so this is a self-consistency assertion, not a runtime
/// condition that can legitimately fail on real hydrology output).
fn d8_direction_between(from: (usize, usize), to: (usize, usize), w: usize) -> u8 {
let dr = to.0 as i32 - from.0 as i32;
// Shortest signed wrap-around column delta in {-1, 0, 1} — the grid wraps
// horizontally (equirectangular), same as every D8 walk in this module.
let raw_dc = to.1 as i32 - from.1 as i32;
let dc = if raw_dc == 0 {
0
} else if raw_dc == 1 || raw_dc == -(w as i32 - 1) {
1
} else if raw_dc == -1 || raw_dc == (w as i32 - 1) {
-1
} else {
panic!(
"d8_direction_between: ({from:?}) -> ({to:?}) is not a D8 neighbor step (dc={raw_dc})"
);
};
for k in 0..D8_LEN {
if d8_offset(k) == (dr, dc) {
return k;
}
}
panic!(
"d8_direction_between: ({from:?}) -> ({to:?}) is not a D8 neighbor step (dr={dr}, dc={dc})"
);
}
// ---------------------------------------------------------------------------
// Priority-flood fill (Barnes/Planchon-Darboux class)
// ---------------------------------------------------------------------------
@@ -1096,4 +1394,412 @@ mod tests {
assert_eq!(channel_depth, vec![0, 120_000, 250_000, 0]);
assert_eq!(cliff_edge, vec![false, true, true, false]);
}
// -----------------------------------------------------------------------
// T-1185 — basin-outlet -> D8 river-network wiring
// -----------------------------------------------------------------------
/// `bowl_grid` at `moisture_q = 100` (well above `ENDORHEIC_MOISTURE_
/// CEILING = 60`) — the SAME single-basin fixture `overflowing_basin_
/// has_nonempty_outlet_path` already relies on to force an `Overflow`
/// classification.
fn overflow_bowl_result() -> HydrologyResult {
let elev = bowl_grid(64, 32);
solve(&elev, 64, 32, 0.0, ClimateInputs { moisture_q: 100 })
}
/// `bowl_grid` at `moisture_q = 0` (well below the ceiling) — forces
/// `Endorheic`, same geometry as [`overflow_bowl_result`] so the two
/// fixtures differ ONLY in classification, not basin shape (mirrors
/// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`'s
/// own straddle-the-ceiling pattern in `layer1.rs`).
fn endorheic_bowl_result() -> HydrologyResult {
let elev = bowl_grid(64, 32);
solve(&elev, 64, 32, 0.0, ClimateInputs { moisture_q: 0 })
}
fn empty_network() -> crate::atlas::body_world_state::RiverNetwork {
crate::atlas::body_world_state::RiverNetwork::default()
}
#[test]
fn overflow_basin_extends_river_network_with_a_new_course() {
let result = overflow_bowl_result();
assert!(
result
.basins
.iter()
.any(|b| matches!(b.outcome, BasinOutcome::Overflow { .. })),
"fixture sanity: the wet bowl must produce at least one Overflow basin"
);
let before = empty_network();
let after = extend_river_network_with_basin_outlets(&result, 64, 32, before.clone());
assert!(
after.river_cells.len() > before.river_cells.len(),
"an Overflow basin's outlet_path must add at least one new river cell — \
this IS the map's 'this lake drains' cue (D-227 amendment (4))"
);
assert_eq!(
after.river_cells.len(),
after.river_class.len(),
"river_cells/river_class must stay parallel arrays"
);
assert_eq!(
after.river_cells.len(),
after.river_downstream.len(),
"river_cells/river_downstream must stay parallel arrays"
);
assert_eq!(
after.river_cells.len(),
after.river_seaward.len(),
"river_cells/river_seaward must stay parallel arrays"
);
// Every appended downstream pointer must be a real D8 direction (0-7)
// or a documented terminus sentinel — never left unset / defaulted.
for &sentinel in &after.river_downstream {
assert!(
sentinel <= crate::atlas::body_world_state::RIVER_DOWNSTREAM_TERMINAL,
"unexpected river_downstream sentinel value: {sentinel}"
);
}
}
#[test]
fn overflow_basin_outlet_survives_build_edges_as_a_real_course() {
// The end-to-end guarantee the map actually reads: an Overflow
// basin's outlet must appear as a real `RiverEdge` once
// `river_course::build_edges` walks the extended network — not just
// as raw array entries on `RiverNetwork` itself.
let result = overflow_bowl_result();
let network = extend_river_network_with_basin_outlets(&result, 64, 32, empty_network());
let edges = crate::atlas::river_course::build_edges(&network);
assert!(
!edges.is_empty(),
"an Overflow basin's outlet_path must produce at least one buildable \
RiverEdge — the exit river the map needs to show"
);
}
#[test]
fn endorheic_basin_adds_no_course_the_positive_absence_cue() {
// The other half of the cue, asserted POSITIVELY (per the ticket):
// an Endorheic basin's lake must end up with NO outgoing course at
// all, not merely "fewer" — this IS the D-227 amendment (4) signal
// ("no course leaving this lake").
let result = endorheic_bowl_result();
assert!(
result
.basins
.iter()
.any(|b| matches!(b.outcome, BasinOutcome::Endorheic { .. })),
"fixture sanity: the dry bowl must produce at least one Endorheic basin"
);
let before = empty_network();
let after = extend_river_network_with_basin_outlets(&result, 64, 32, before.clone());
assert_eq!(
after.river_cells, before.river_cells,
"an Endorheic basin must contribute ZERO new river cells — the network \
is byte-identical to the unextended input"
);
assert_eq!(after.river_class, before.river_class);
assert_eq!(after.river_downstream, before.river_downstream);
assert_eq!(after.river_seaward, before.river_seaward);
// And the end-to-end read: build_edges over the (unchanged) network
// yields no edges either, since the input network started empty.
let edges = crate::atlas::river_course::build_edges(&after);
assert!(
edges.is_empty(),
"an Endorheic-only basin set must produce zero courses — 'no course \
leaves this lake' is the whole cue"
);
}
#[test]
fn basin_outlet_wiring_is_deterministic() {
// Two independent solves + wirings from the same inputs must be
// byte-identical (D-010) — the mandatory determinism gate every
// seed-chaining mechanism in this codebase carries.
let elev = bowl_grid(64, 32);
let climate = ClimateInputs { moisture_q: 100 };
let r1 = solve(&elev, 64, 32, 0.0, climate);
let r2 = solve(&elev, 64, 32, 0.0, climate);
let n1 = extend_river_network_with_basin_outlets(&r1, 64, 32, empty_network());
let n2 = extend_river_network_with_basin_outlets(&r2, 64, 32, empty_network());
assert_eq!(n1.river_cells, n2.river_cells);
assert_eq!(n1.river_class, n2.river_class);
assert_eq!(n1.river_downstream, n2.river_downstream);
assert_eq!(n1.river_seaward, n2.river_seaward);
}
#[test]
fn endorheic_basin_wiring_is_also_deterministic() {
let elev = bowl_grid(64, 32);
let climate = ClimateInputs { moisture_q: 0 };
let r1 = solve(&elev, 64, 32, 0.0, climate);
let r2 = solve(&elev, 64, 32, 0.0, climate);
let n1 = extend_river_network_with_basin_outlets(&r1, 64, 32, empty_network());
let n2 = extend_river_network_with_basin_outlets(&r2, 64, 32, empty_network());
assert_eq!(n1.river_cells, n2.river_cells);
assert_eq!(n1.river_downstream, n2.river_downstream);
}
#[test]
fn terminal_sentinel_on_a_preexisting_cell_is_still_a_safe_no_op_in_build_edges() {
// T-1170 Ruling 7b's own guarantee, re-exercised here at the
// integration seam this ticket wires up: a RIVER_DOWNSTREAM_TERMINAL
// entry anywhere in `river_downstream` must never break
// `build_edges`, regardless of whether the entry came from the
// original D8 extraction or (hypothetically) sat alongside T-1185's
// appended cells. `extend_river_network_with_basin_outlets` itself
// never emits TERMINAL (Endorheic basins are skipped outright, never
// mapped to the sentinel) — this test pins that build_edges' existing
// no-op handling still holds on a network T-1185's function has
// touched, not just a hand-built one.
let result = overflow_bowl_result();
let mut network = extend_river_network_with_basin_outlets(&result, 64, 32, empty_network());
assert!(
!network.river_cells.is_empty(),
"fixture sanity: the overflow bowl must have appended at least one cell"
);
// Manually inject a TERMINAL entry (simulating a future endorheic
// interior-sink emission from elsewhere in the pipeline, per Ruling
// 7b's reservation) at the first appended cell.
network.river_downstream[0] = crate::atlas::body_world_state::RIVER_DOWNSTREAM_TERMINAL;
let edges = crate::atlas::river_course::build_edges(&network);
// No panic, and the TERMINAL cell itself produces no edge (build_edges'
// own documented no-op) — the remaining cells (if any) are unaffected.
assert!(
edges.iter().all(|e| e.upstream != network.river_cells[0]),
"a RIVER_DOWNSTREAM_TERMINAL cell must never produce a RiverEdge"
);
}
#[test]
fn extend_river_network_skips_cells_already_present() {
// A hand-built network that already contains the bowl's expected
// outlet cell(s) must not gain duplicate parallel-array entries —
// the "re-entered the pre-existing D8 network" branch.
let result = overflow_bowl_result();
let overflow = result
.basins
.iter()
.find(|b| matches!(b.outcome, BasinOutcome::Overflow { .. }))
.expect("fixture sanity: at least one Overflow basin");
let BasinOutcome::Overflow { outlet_path, .. } = &overflow.outcome else {
unreachable!()
};
assert!(
outlet_path.len() >= 2,
"fixture sanity: outlet_path must have at least one interior cell \
to pre-seed as already-present"
);
let pre_existing_cell = outlet_path[1];
let (r, c) = (pre_existing_cell / 64, pre_existing_cell % 64);
let mut network = empty_network();
network.river_cells.push((r as u16, c as u16));
network.river_class.push(1);
network
.river_downstream
.push(crate::atlas::body_world_state::RIVER_DOWNSTREAM_EDGE_DRAIN);
network.river_seaward.push((0, 0));
let after = extend_river_network_with_basin_outlets(&result, 64, 32, network);
// The pre-existing cell must appear exactly once, not twice.
let occurrences = after
.river_cells
.iter()
.filter(|&&cell| cell == (r as u16, c as u16))
.count();
assert_eq!(
occurrences, 1,
"a basin-outlet cell already present in the network must not be duplicated"
);
assert_eq!(
after.river_cells.len(),
after.river_class.len(),
"parallel arrays must stay in sync even when a duplicate is skipped"
);
}
/// PR #202 review, Hoshe finding 1 — the i==1 collision regression,
/// asserted NON-VACUOUSLY on the actual D-227 amendment (4) cue: when
/// `outlet_path[1]` (the FIRST interior cell after the spill) is already
/// a pre-existing river cell, the basin's Overflow status must still be
/// READABLE via `build_edges` — a course must leave the lake. Before the
/// fix, this exact scenario made the loop `break` on its first
/// iteration having pushed zero cells for the basin, silently making a
/// real Overflow basin indistinguishable from Endorheic.
#[test]
fn overflow_basin_survives_an_i_equals_one_collision_non_vacuously() {
let result = overflow_bowl_result();
let overflow = result
.basins
.iter()
.find(|b| matches!(b.outcome, BasinOutcome::Overflow { .. }))
.expect("fixture sanity: at least one Overflow basin");
let BasinOutcome::Overflow { outlet_path, .. } = &overflow.outcome else {
unreachable!()
};
assert!(
outlet_path.len() >= 2,
"fixture sanity: outlet_path must have an interior cell at index 1 \
to pre-seed as the i==1 collision"
);
let spill = outlet_path[0];
let collision_cell = outlet_path[1]; // the exact i==1 collision position
let (sr, sc) = (spill / 64, spill % 64);
let (cr, cc) = (collision_cell / 64, collision_cell % 64);
// Pre-seed the network with ONLY the i==1 cell as a native river
// cell (pointing at some unrelated existing direction) — the spill
// cell itself is NOT pre-existing, isolating this as the exact
// "first interior cell collides" scenario Hoshe's finding names.
let mut network = empty_network();
network.river_cells.push((cr as u16, cc as u16));
network.river_class.push(1);
network
.river_downstream
.push(crate::atlas::body_world_state::RIVER_DOWNSTREAM_EDGE_DRAIN);
network.river_seaward.push((0, 0));
let after = extend_river_network_with_basin_outlets(&result, 64, 32, network);
// The spill cell must have its OWN entry — this is the actual fix:
// before it, nothing was ever pushed for this basin at all.
let spill_idx = after
.river_cells
.iter()
.position(|&cell| cell == (sr as u16, sc as u16))
.expect(
"the spill cell must have a real river_cells entry even when \
outlet_path[1] collides with a pre-existing river cell — this \
is the exact bug PR #202 flagged: a silent break at i==1 must \
never erase the basin's own outflow anchor",
);
// Its downstream pointer must be a real D8 direction (not a sentinel)
// toward the collision cell, since outlet_path[1] IS the next hop.
assert!(
after.river_downstream[spill_idx] < 8,
"the spill cell's downstream pointer must be a real D8 direction \
toward outlet_path[1], not a terminus sentinel"
);
// Non-vacuous on the cue itself: build_edges must produce a real
// edge whose upstream IS the spill cell — an overflow lake's exit
// river must be readable from the served network, not just present
// as raw unreachable array data.
let edges = crate::atlas::river_course::build_edges(&after);
assert!(
edges.iter().any(|e| e.upstream == (sr as u16, sc as u16)),
"an Overflow basin's spill cell must produce a real RiverEdge even \
when its first interior outlet cell collides with a pre-existing \
river cell — otherwise this basin is visually indistinguishable \
from Endorheic, which is exactly the cue this ticket must not break"
);
}
/// The sibling collision case: the SPILL CELL ITSELF (not an interior
/// cell) is already a pre-existing river cell — verified to occur on
/// real GJ1c data (2/51 real Overflow basins). The pre-existing entry's
/// downstream pointer must be overwritten to point along the outlet
/// path (the hydrology solve is the more authoritative answer for this
/// cell's true direction), not left pointing wherever the original flat
/// D8 extraction guessed — and it must not create a duplicate
/// `river_cells` entry at the same position.
#[test]
fn overflow_basin_overwrites_a_pre_existing_spill_cell_in_place() {
let result = overflow_bowl_result();
let overflow = result
.basins
.iter()
.find(|b| matches!(b.outcome, BasinOutcome::Overflow { .. }))
.expect("fixture sanity: at least one Overflow basin");
let BasinOutcome::Overflow { outlet_path, .. } = &overflow.outcome else {
unreachable!()
};
assert!(
outlet_path.len() >= 2,
"fixture sanity: needs an interior cell"
);
let spill = outlet_path[0];
let (sr, sc) = (spill / 64, spill % 64);
// Pre-seed the network with the SPILL cell as a native river cell,
// pointing in an unrelated direction (simulating the original flat
// D8 extraction having already claimed this cell for its own,
// wrong-for-the-lake reasons).
let mut network = empty_network();
network.river_cells.push((sr as u16, sc as u16));
network.river_class.push(2); // deliberately a different class
network.river_downstream.push(0); // deliberately a real but likely-wrong direction
network.river_seaward.push((0, 0));
let after = extend_river_network_with_basin_outlets(&result, 64, 32, network);
// Exactly one entry at the spill position — overwritten, not duplicated.
let occurrences = after
.river_cells
.iter()
.filter(|&&cell| cell == (sr as u16, sc as u16))
.count();
assert_eq!(
occurrences, 1,
"the spill cell must be overwritten in place, never duplicated"
);
let spill_idx = after
.river_cells
.iter()
.position(|&cell| cell == (sr as u16, sc as u16))
.unwrap();
assert!(
after.river_downstream[spill_idx] < 8,
"the overwritten spill cell must point along the real outlet path, \
not retain whatever direction the pre-existing entry had"
);
// Non-vacuous on the cue: the overwritten spill cell must still
// build into a real, readable RiverEdge.
let edges = crate::atlas::river_course::build_edges(&after);
assert!(
edges.iter().any(|e| e.upstream == (sr as u16, sc as u16)),
"an overwritten spill cell must still produce a real RiverEdge — \
the lake's outflow must remain readable"
);
}
#[test]
fn d8_direction_between_matches_the_offset_table() {
// Round-trip sanity: for every direction in the D8 table, stepping
// from an interior cell and asking for the direction back must
// recover the same index.
let w = 16usize;
let from = (8usize, 8usize);
for k in 0..D8_LEN {
let (dr, dc) = d8_offset(k);
let to = (
(from.0 as i32 + dr) as usize,
(from.1 as i32 + dc).rem_euclid(w as i32) as usize,
);
assert_eq!(
d8_direction_between(from, to, w),
k,
"round-trip mismatch for D8 direction {k}"
);
}
}
#[test]
fn d8_direction_between_handles_horizontal_wraparound() {
// Column 0 -> column (w-1) is a valid D8 West step under horizontal
// wraparound (the equirectangular grid convention every D8 walk in
// this module already uses) — must resolve to West (k=3), not panic.
let w = 16usize;
let from = (5usize, 0usize);
let to = (5usize, w - 1);
assert_eq!(
d8_direction_between(from, to, w),
3,
"expected West (wrapped)"
);
}
}
+145 -1
View File
@@ -83,6 +83,23 @@ pub struct Layer1Output {
/// used ONLY by [`run_layer1`]'s two-arg form; [`run_layer1_with_moisture`]
/// (called by every production site that has real `BodyParams` in scope) never
/// reaches this constant.
///
/// **This constant is NOT what a player sees on any real body (PR #202
/// review, Hoshe finding 2) — never cite a basin's Overflow/Endorheic split
/// at this moisture value as representative of production.** The
/// endorheic-vs-overflow decision (`hydrology_equilibrium::is_endorheic`)
/// gates on `moisture_q <= ENDORHEIC_MOISTURE_CEILING (60)`; this fallback
/// (55) sits just BELOW that ceiling, so a fallback-path solve can show
/// Endorheic basins that would never occur on the real body. Concretely, on
/// GJ1c (`hydrosphere: liquid_water`, `atmosphere: standard` →
/// `derive_moisture_ceiling_q` = 80, well ABOVE the ceiling): the fallback
/// path (55) splits its 53 real basins 51 Overflow / 2 Endorheic, while the
/// PRODUCTION path (80, via `run_layer1_with_moisture` at a real call site)
/// is 53/53 all-Overflow on the identical geometry — moisture is the only
/// variable that moves (`filled_scaled`/basin geometry itself is
/// moisture-independent). Any doc, golden comment, or report citing a
/// basin-outcome ratio must state which moisture path produced it — see
/// `tests/cascade_golden.rs`'s own re-pin note for the worked example.
const DEFAULT_HYDROLOGY_MOISTURE_Q: i32 = 55;
/// Run the Layer-1 topography pipeline for a single body.
@@ -143,6 +160,20 @@ pub fn run_layer1_with_moisture(
ta = ta.with_hydrology(&hm.data, &hydrology);
let raw = features::extract_attractors(hm, &drainage, &ta);
// T-1185: thread the settled-hydrology overflow basins' resolved
// outlet_path/spill points into the D8 river network as downstream
// continuations (D-227 amendment (4)'s endorheic cue: outflow-course
// PRESENCE, no wire bit). Deliberately run AFTER `extract_attractors`
// (which reads `drainage.river_network`/`drainage.fdir` — the PRE-
// extension network) so basin-outlet cells never perturb geographic
// attractor placement, a different subsystem this ticket does not touch.
let river_network =
crate::atlas::hydrology_equilibrium::extend_river_network_with_basin_outlets(
&hydrology,
hm.width,
hm.height,
drainage.river_network,
);
let attractors: Vec<GeographicAttractor> = raw
.iter()
.map(|r| {
@@ -179,7 +210,7 @@ pub fn run_layer1_with_moisture(
let l1 = Layer1Output {
body_id: hm.body_id.clone(),
river_network: drainage.river_network,
river_network,
drainage_basins: drainage.drainage_basins,
attractors,
grid_w: hm.width,
@@ -573,4 +604,117 @@ mod tests {
"moisture_q=100 (well above the ceiling) must classify Overflow; got {wet_outcome:?}"
);
}
// -------------------------------------------------------------------
// T-1185 — basin-outlet -> course-network wiring, end-to-end through
// run_layer1_with_moisture (the real production call site).
// -------------------------------------------------------------------
#[test]
fn run_layer1_wires_overflow_basin_outlet_into_served_river_network() {
// moisture_q=100 straddles well above ENDORHEIC_MOISTURE_CEILING=60
// (same bowl fixture/moisture split as
// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`)
// — the bowl's single basin must classify Overflow, and its
// outlet_path must appear as real river cells in the SERVED
// Layer1Output.river_network — not just in the raw HydrologyResult.
let h = bowl_hm(64, 32, "BowlBody");
let baseline = drainage::analyze(&h.data, h.width, h.height, h.sea_level).river_network;
let (l1, _ta) = run_layer1_with_moisture(&h, 100);
assert!(
l1.river_network.river_cells.len() > baseline.river_cells.len(),
"the Overflow basin's outlet extension must add river cells beyond \
whatever drainage::analyze already extracted on its own (isolates \
the T-1185 wiring from the pre-existing D8 baseline)"
);
let edges = crate::atlas::river_course::build_edges(&l1.river_network);
assert!(
!edges.is_empty(),
"an Overflow bowl basin must produce at least one buildable RiverEdge \
in the production Layer1Output — the map's 'this lake drains' cue"
);
}
#[test]
fn run_layer1_endorheic_basin_adds_no_outlet_courses_beyond_baseline() {
// Same geometry, moisture_q=0 (well below the ceiling) forces
// Endorheic — the T-1185 extension must add ZERO river cells beyond
// whatever `drainage::analyze` already extracted on its own (the
// pre-existing D8 network the bowl's own rim slope forms is an
// unrelated baseline this ticket must not perturb; the cue is about
// whether the BASIN gains an outlet, not about the network being
// literally empty).
let h = bowl_hm(64, 32, "BowlBody");
let baseline = drainage::analyze(&h.data, h.width, h.height, h.sea_level).river_network;
let (l1, _ta) = run_layer1_with_moisture(&h, 0);
// Re-solve directly to confirm the fixture actually IS Endorheic at
// this moisture (non-vacuous — mirrors the sibling wet-basin test's
// own discipline).
let result = crate::atlas::hydrology_equilibrium::solve(
&h.data,
h.width,
h.height,
h.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 0 },
);
assert!(
result.basins.iter().any(|b| matches!(
b.outcome,
crate::atlas::hydrology_equilibrium::BasinOutcome::Endorheic { .. }
)),
"fixture sanity: moisture_q=0 must classify the bowl basin Endorheic"
);
assert_eq!(
l1.river_network.river_cells, baseline.river_cells,
"an Endorheic basin must add ZERO river cells beyond the pre-existing \
D8-extracted baseline — no outlet courses for a closed basin"
);
}
#[test]
fn run_layer1_basin_outlet_wiring_is_deterministic() {
// D-010: two independent `run_layer1_with_moisture` calls on the
// same input must produce byte-identical served river networks,
// including the T-1185 basin-outlet extension.
let h = bowl_hm(64, 32, "BowlBody");
let (l1_a, _) = run_layer1_with_moisture(&h, 100);
let (l1_b, _) = run_layer1_with_moisture(&h, 100);
assert_eq!(
l1_a.river_network.river_cells,
l1_b.river_network.river_cells
);
assert_eq!(
l1_a.river_network.river_downstream,
l1_b.river_network.river_downstream
);
assert_eq!(
l1_a.river_network.river_class,
l1_b.river_network.river_class
);
assert_eq!(
l1_a.river_network.river_seaward,
l1_b.river_network.river_seaward
);
}
#[test]
fn run_layer1_basin_outlet_extension_does_not_perturb_attractors() {
// Sanity guard for the ordering decision documented at the call
// site: `extend_river_network_with_basin_outlets` runs AFTER
// `extract_attractors`, so basin-outlet cells must never change
// attractor placement. Compare against a bowl body at a moisture
// level that produces NO overflow extension (fully dry, endorheic)
// vs. one that does (wet) — attractors must be identical, since
// attractor extraction only ever sees the pre-extension drainage.
let h = bowl_hm(64, 32, "BowlBody");
let (l1_dry, _) = run_layer1_with_moisture(&h, 0);
let (l1_wet, _) = run_layer1_with_moisture(&h, 100);
assert_eq!(
l1_dry.attractors.len(),
l1_wet.attractors.len(),
"basin-outlet wiring (which differs between these two moisture \
levels) must not change attractor extraction, which runs on the \
pre-extension drainage network"
);
}
}
+56
View File
@@ -56,6 +56,62 @@
//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by
//! this second re-pin (93/3/256) — purely the new parallel array, 3 non-
//! placeholder entries (one per real mouth).
//!
//! **Third deliberate re-pin (T-1185, D-227 amendment (4) — basin-outlet →
//! D8 river-network wiring):** `run_layer1`/`run_layer1_with_moisture` now
//! extends the D8-extracted `RiverNetwork` with every `BasinOutcome::
//! Overflow` basin's `outlet_path` as new river cells (the endorheic cue:
//! outflow-course PRESENCE). `attractors` (256) and `drainage_basins` are
//! UNCHANGED — the extension runs strictly after
//! `features::extract_attractors`, by design, so basin-outlet cells never
//! perturb geographic attractor placement. `mouths`/`confluences` also
//! unchanged (T-1185 never rewrites those arrays).
//!
//! **This golden pins the FALLBACK moisture path, not production — stated
//! explicitly so nobody cites its basin-outcome split as what a player
//! actually sees (PR #202 review, Hoshe finding 2).**
//! `run_cascade_from_heightmap`'s `body_params: None` argument here means
//! `run_layer1`'s `DEFAULT_HYDROLOGY_MOISTURE_Q = 55` fallback is what
//! solves this fixture's hydrology, not GJ1c's REAL body params
//! (`wiki/star-systems/GJ-1/bodies/GJ1c/index.md`: `hydrosphere:
//! liquid_water`, `atmosphere: standard` → `derive_moisture_ceiling_q`
//! yields **moisture_q=80**, well above `ENDORHEIC_MOISTURE_CEILING=60`).
//! At the fallback `moisture_q=55` this fixture's 53 real working-grid
//! basins split 51 Overflow / 2 Endorheic; verified directly (a scratch
//! probe, not committed) that at the PRODUCTION `moisture_q=80` the SAME
//! 53 basins are **53/53 all-Overflow — zero Endorheic** (moisture is the
//! only thing that moves; `filled_scaled`/basin geometry itself is
//! moisture-independent, per `run_layer1_with_moisture_changes_
//! endorheic_split_not_lake_extent`'s own invariant). A materially
//! different picture: on the real body, every one of these basins shows an
//! exit river on the map — this fixture happening to include 2 Endorheic
//! basins is an artifact of testing at the body-agnostic fallback
//! constant, not a fact about GJ1c itself.
//!
//! **Cell count (post PR #202 review fix — the i==1/spill-collision
//! regression, Hoshe finding 1):** `river_cells` 93→192 (+99). Every one of
//! the 51 real `Overflow` basins now has its spill cell wired as a real
//! river-network entry, unconditionally — the earlier landing (143, now
//! superseded) silently dropped a basin's ENTIRE outlet whenever
//! `outlet_path[1]` collided with a pre-existing river cell (the loop broke
//! on its first iteration having pushed nothing), which on THIS fixture
//! happened for 1 basin outright and would have made it visually
//! indistinguishable from Endorheic — exactly the cue this ticket must
//! never break. Verified directly against this fixture (scratch probe, not
//! committed): all 51 real `Overflow` basins now build into a real,
//! readable `RiverEdge` via `river_course::build_edges` — zero basins
//! missing an outlet edge. One pre-existing baseline cell (`(28, 14)`) is
//! overwritten in place (its `river_downstream` sentinel changes from
//! `RIVER_DOWNSTREAM_EDGE_DRAIN` to a real D8 direction) rather than
//! duplicated — the other real spill-cell collision on this fixture lands
//! among the newly-appended range, not the original 93-cell baseline.
//! Confirmed by direct position-identity diff (not raw array-index
//! comparison, which is misleading once the fix reshuffles positions
//! within each basin's block): zero positions are ever REMOVED between the
//! pre-fix and post-fix goldens, only added-or-overwritten — the fix is
//! additive at the position level, exactly as designed. No duplicate
//! `(row, col)` positions exist in the final array (verified — the
//! `edge_ids_are_unique` invariant `build_edges` depends on holds).
use std::path::PathBuf;
File diff suppressed because it is too large Load Diff