diff --git a/CHANGELOG.md b/CHANGELOG.md index 647c40914..0374bfc59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-248–D-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 diff --git a/server/src/atlas/hydrology_equilibrium.rs b/server/src/atlas/hydrology_equilibrium.rs index 50b963990..3399bc43c 100644 --- a/server/src/atlas/hydrology_equilibrium.rs +++ b/server/src/atlas/hydrology_equilibrium.rs @@ -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> = 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)" + ); + } } diff --git a/server/src/atlas/layer1.rs b/server/src/atlas/layer1.rs index ccbe8fb1f..739765eff 100644 --- a/server/src/atlas/layer1.rs +++ b/server/src/atlas/layer1.rs @@ -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 = 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" + ); + } } diff --git a/server/tests/cascade_golden.rs b/server/tests/cascade_golden.rs index a919061eb..dda47ece2 100644 --- a/server/tests/cascade_golden.rs +++ b/server/tests/cascade_golden.rs @@ -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; diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index 0e8c58627..5e25ace28 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -8151,6 +8151,402 @@ [ 124, 239 + ], + [ + 2, + 99 + ], + [ + 1, + 100 + ], + [ + 2, + 137 + ], + [ + 1, + 138 + ], + [ + 8, + 194 + ], + [ + 7, + 195 + ], + [ + 8, + 204 + ], + [ + 7, + 204 + ], + [ + 10, + 68 + ], + [ + 10, + 69 + ], + [ + 14, + 29 + ], + [ + 14, + 28 + ], + [ + 13, + 47 + ], + [ + 14, + 48 + ], + [ + 13, + 172 + ], + [ + 14, + 173 + ], + [ + 18, + 179 + ], + [ + 19, + 180 + ], + [ + 24, + 152 + ], + [ + 25, + 152 + ], + [ + 20, + 165 + ], + [ + 21, + 166 + ], + [ + 16, + 199 + ], + [ + 17, + 200 + ], + [ + 16, + 6 + ], + [ + 17, + 6 + ], + [ + 19, + 181 + ], + [ + 20, + 181 + ], + [ + 17, + 45 + ], + [ + 16, + 46 + ], + [ + 17, + 92 + ], + [ + 17, + 93 + ], + [ + 17, + 126 + ], + [ + 17, + 127 + ], + [ + 19, + 223 + ], + [ + 19, + 224 + ], + [ + 22, + 231 + ], + [ + 21, + 232 + ], + [ + 32, + 254 + ], + [ + 33, + 255 + ], + [ + 26, + 103 + ], + [ + 27, + 104 + ], + [ + 29, + 15 + ], + [ + 30, + 61 + ], + [ + 31, + 61 + ], + [ + 27, + 237 + ], + [ + 27, + 236 + ], + [ + 29, + 138 + ], + [ + 30, + 138 + ], + [ + 31, + 9 + ], + [ + 31, + 10 + ], + [ + 34, + 137 + ], + [ + 34, + 136 + ], + [ + 34, + 156 + ], + [ + 35, + 155 + ], + [ + 37, + 73 + ], + [ + 37, + 72 + ], + [ + 39, + 64 + ], + [ + 39, + 63 + ], + [ + 40, + 235 + ], + [ + 41, + 235 + ], + [ + 45, + 123 + ], + [ + 44, + 122 + ], + [ + 52, + 241 + ], + [ + 51, + 242 + ], + [ + 51, + 0 + ], + [ + 52, + 1 + ], + [ + 51, + 109 + ], + [ + 50, + 108 + ], + [ + 56, + 40 + ], + [ + 57, + 41 + ], + [ + 57, + 63 + ], + [ + 56, + 64 + ], + [ + 58, + 215 + ], + [ + 57, + 216 + ], + [ + 62, + 43 + ], + [ + 62, + 42 + ], + [ + 70, + 233 + ], + [ + 70, + 232 + ], + [ + 74, + 228 + ], + [ + 74, + 229 + ], + [ + 74, + 230 + ], + [ + 74, + 231 + ], + [ + 91, + 214 + ], + [ + 92, + 213 + ], + [ + 88, + 37 + ], + [ + 88, + 38 + ], + [ + 97, + 89 + ], + [ + 96, + 88 + ], + [ + 101, + 215 + ], + [ + 101, + 214 + ], + [ + 115, + 40 + ], + [ + 114, + 41 + ], + [ + 115, + 60 + ], + [ + 115, + 59 + ], + [ + 116, + 220 + ], + [ + 117, + 221 + ], + [ + 124, + 25 + ], + [ + 124, + 24 ] ], "river_class": [ @@ -8208,7 +8604,7 @@ 0, 1, 0, - 2, + 0, 1, 0, 1, @@ -8246,6 +8642,105 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 0 ], "river_downstream": [ @@ -8303,7 +8798,7 @@ 4, 9, 7, - 9, + 6, 3, 3, 9, @@ -8341,7 +8836,106 @@ 5, 9, 2, - 8 + 8, + 4, + 9, + 4, + 9, + 4, + 9, + 0, + 9, + 2, + 9, + 3, + 9, + 6, + 9, + 6, + 9, + 6, + 9, + 1, + 9, + 6, + 9, + 6, + 9, + 1, + 9, + 1, + 9, + 4, + 9, + 2, + 9, + 2, + 9, + 2, + 9, + 4, + 9, + 6, + 9, + 6, + 9, + 9, + 1, + 9, + 3, + 9, + 1, + 9, + 2, + 9, + 3, + 9, + 7, + 9, + 3, + 9, + 3, + 9, + 1, + 9, + 5, + 9, + 4, + 9, + 6, + 9, + 5, + 9, + 6, + 9, + 4, + 9, + 4, + 9, + 3, + 9, + 3, + 9, + 2, + 9, + 2, + 9, + 7, + 9, + 2, + 9, + 5, + 9, + 3, + 9, + 4, + 9, + 3, + 9, + 6, + 9, + 3, + 9 ], "river_seaward": [ [ @@ -8715,6 +9309,402 @@ [ 125, 240 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 ] ] }