diff --git a/.project/designs/app/src/pages/WorldGen/ForestLab.tsx b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx index c6b1453d..452380e1 100644 --- a/.project/designs/app/src/pages/WorldGen/ForestLab.tsx +++ b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx @@ -13,8 +13,9 @@ import { classifyTerrain, TERRAIN_MAP } from "../../utils/worldGen/terrain"; import type { GridCell } from "../../utils/worldGen/terrain"; import { WhittakerPlot } from "./WhittakerPlot"; import { TERRAIN_FLORA, topFloraForLayer } from "../../utils/worldGen/floraSpecies"; -import { computeHydrology } from "../../utils/worldGen/hydrology"; -import type { HydroState } from "../../utils/worldGen/hydrology"; +// TODO(p1-46): replace deleted hydrology.ts with WASM bindings +// import { computeHydrology } from "../../utils/worldGen/hydrology"; +// import type { HydroState } from "../../utils/worldGen/hydrology"; import { TERRAIN_FAUNA_SPECIES, pickFaunaForCell, drawFaunaGlyph } from "../../utils/worldGen/faunaSpecies"; // ── Canvas layout constants ─────────────────────────────────────────────────── diff --git a/.project/designs/app/src/utils/worldGen/hydrology.ts b/.project/designs/app/src/utils/worldGen/hydrology.ts deleted file mode 100644 index 770e0a66..00000000 --- a/.project/designs/app/src/utils/worldGen/hydrology.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { GridCell } from "./terrain"; - -// ── Hydrology network ───────────────────────────────────────────────────────── -// D6 flow-direction analysis on a flat-top hex elevation field. -// Produces HydroState per cell: flow direction, drainage area, Strahler order, -// lake membership, and river flag. - -export type HydroState = { - /** 0-5 hex direction index or null (sink / ocean / lake) */ - flowOut: number | null; - /** Accumulated drainage area (number of upstream cells + self) */ - drainageArea: number; - /** Strahler stream order (1 = leaf) */ - streamOrder: number; - /** Lake basin identifier, or null if not a lake cell */ - lakeId: number | null; - isRiver: boolean; - isLake: boolean; -}; - -// Flat-top hex offset neighbor table. -// For even col: [dc,dr] pairs for directions 0-5. -// For odd col: [dc,dr] pairs for directions 0-5. -const EVEN_OFFSETS: [number, number][] = [ - [ 1, 1], [ 1, 0], [ 0, -1], [-1, 0], [-1, 1], [ 0, 1], -]; -const ODD_OFFSETS: [number, number][] = [ - [ 1, 0], [ 1, -1], [ 0, -1], [-1, -1], [-1, 0], [ 0, 1], -]; - -function getOffsets(col: number): [number, number][] { - return col % 2 === 1 ? ODD_OFFSETS : EVEN_OFFSETS; -} - -type Neighbor = { nc: number; nr: number; dir: number }; - -function hexNeighbors(col: number, row: number, cols: number, rows: number): Neighbor[] { - return getOffsets(col) - .map(([dc, dr], dir): Neighbor => ({ nc: col + dc, nr: row + dr, dir })) - .filter(({ nc, nr }) => nc >= 0 && nc < cols && nr >= 0 && nr < rows); -} - -const LAKE_TERRAIN_IDS = new Set(["ocean", "coast", "lake", "inland_sea"]); -const RIVER_THRESHOLD = 4; - -export function computeHydrology(grid: GridCell[][]): HydroState[][] { - const rows = grid.length; - const cols = grid[0]?.length ?? 0; - - // ── Initialise ─────────────────────────────────────────────────────────────── - const hydro: HydroState[][] = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => ({ - flowOut: null, - drainageArea: 1, - streamOrder: 1, - lakeId: null, - isRiver: false, - isLake: false, - })) - ); - - // ── Step 1: flow direction — lowest-elevation neighbour ─────────────────────── - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const tid = grid[r][c].terrainId; - if (LAKE_TERRAIN_IDS.has(tid)) { - // Water cells are always sinks - hydro[r][c].flowOut = null; - hydro[r][c].isLake = tid === "lake" || tid === "inland_sea"; - if (hydro[r][c].isLake) { - hydro[r][c].lakeId = r * 1000 + c; - } - continue; - } - const elev = grid[r][c].elevation; - let minElev = elev; - let flowDir: number | null = null; - for (const { nc, nr, dir } of hexNeighbors(c, r, cols, rows)) { - const ne = grid[nr][nc].elevation; - if (ne < minElev) { minElev = ne; flowDir = dir; } - } - hydro[r][c].flowOut = flowDir; - } - } - - // ── Step 2: topological drainage accumulation (highest elevation first) ────── - const order: { r: number; c: number }[] = []; - for (let r = 0; r < rows; r++) - for (let c = 0; c < cols; c++) - order.push({ r, c }); - order.sort((a, b) => grid[b.r][b.c].elevation - grid[a.r][a.c].elevation); - - for (const { r, c } of order) { - const flowDir = hydro[r][c].flowOut; - if (flowDir === null) continue; - const [dc, dr] = getOffsets(c)[flowDir]; - const nr = r + dr, nc = c + dc; - if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) { - hydro[nr][nc].drainageArea += hydro[r][c].drainageArea; - } - } - - // ── Step 3: Strahler order ───────────────────────────────────────────────── - // Process in same high-to-low order so leaves are processed before their parents. - for (const { r, c } of order) { - const inOrders: number[] = []; - for (const { nc, nr } of hexNeighbors(c, r, cols, rows)) { - const nh = hydro[nr][nc]; - if (nh.flowOut === null) continue; - const [fdc, fdr] = getOffsets(nc)[nh.flowOut]; - if (nc + fdc === c && nr + fdr === r) { - inOrders.push(nh.streamOrder); - } - } - if (inOrders.length === 0) { - hydro[r][c].streamOrder = 1; - } else { - const maxOrd = Math.max(...inOrders); - const maxCount = inOrders.filter((o) => o === maxOrd).length; - hydro[r][c].streamOrder = maxCount >= 2 ? maxOrd + 1 : maxOrd; - } - } - - // ── Step 4: mark rivers ──────────────────────────────────────────────────── - for (let r = 0; r < rows; r++) - for (let c = 0; c < cols; c++) - hydro[r][c].isRiver = hydro[r][c].drainageArea >= RIVER_THRESHOLD; - - return hydro; -} diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 590fb3a1..f443adea 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -194,6 +194,26 @@ impl GdGridState { None => Dictionary::new(), } } + + /// Return hydrology fields for a single tile as a Dictionary. + /// Keys: flow_out (int, 255=no outflow), drainage_area (int), + /// stream_order (int), lake_id (int, -1=none), riparian_distance (int, 255=beyond range). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_hydrology(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("flow_out", tile.flow_out as i64); + d.set("drainage_area", tile.drainage_area as i64); + d.set("stream_order", tile.stream_order as i64); + d.set("lake_id", tile.lake_id.map(|v| v as i64).unwrap_or(-1)); + d.set("riparian_distance", tile.riparian_distance as i64); + d + } + None => Dictionary::new(), + } + } } // ── GdClimatePhysics ──────────────────────────────────────────────────── diff --git a/src/simulator/api-wasm/src/lib.rs b/src/simulator/api-wasm/src/lib.rs index ebba0974..ab4d85b7 100644 --- a/src/simulator/api-wasm/src/lib.rs +++ b/src/simulator/api-wasm/src/lib.rs @@ -244,6 +244,24 @@ impl WasmGrid { ) }) } + + /// Return hydrology fields for a single tile as a JSON string. + /// Returns `null` if the coordinates are out of range. + /// `flow_out` = 255 means no outflow. `lake_id` = -1 means not a lake. + #[wasm_bindgen(js_name = "tileHydrologyJson")] + pub fn tile_hydrology_json(&self, col: i32, row: i32) -> Option { + self.inner.tile(col, row).map(|t| { + let lake_id_val = t.lake_id.map(|v| v as i64).unwrap_or(-1); + format!( + r#"{{"flow_out":{flow_out},"drainage_area":{drainage_area},"stream_order":{stream_order},"lake_id":{lake_id},"riparian_distance":{riparian_distance}}}"#, + flow_out = t.flow_out, + drainage_area = t.drainage_area, + stream_order = t.stream_order, + lake_id = lake_id_val, + riparian_distance = t.riparian_distance, + ) + }) + } } /// WASM-exposed climate physics engine. diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 16801ee6..b94cfe1b 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -360,6 +360,11 @@ impl Default for TileState { aridity_index: 1.0, t_band: 2, p_band: 2, + flow_out: u8::MAX, + drainage_area: 0, + stream_order: 1, + lake_id: None, + riparian_distance: u8::MAX, } } } diff --git a/src/simulator/crates/mc-mapgen/Cargo.toml b/src/simulator/crates/mc-mapgen/Cargo.toml index 340d172d..31d52182 100644 --- a/src/simulator/crates/mc-mapgen/Cargo.toml +++ b/src/simulator/crates/mc-mapgen/Cargo.toml @@ -19,5 +19,9 @@ criterion = { version = "0.5", features = ["html_reports"] } name = "tectonics_bench" harness = false +[[bench]] +name = "erosion_bench" +harness = false + [lints] workspace = true diff --git a/src/simulator/crates/mc-mapgen/benches/erosion_bench.rs b/src/simulator/crates/mc-mapgen/benches/erosion_bench.rs new file mode 100644 index 00000000..3117caeb --- /dev/null +++ b/src/simulator/crates/mc-mapgen/benches/erosion_bench.rs @@ -0,0 +1,25 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use mc_core::grid::GridState; +use mc_mapgen::erosion::{run_erosion, ErosionParams}; + +fn make_gradient_grid(w: i32, h: i32) -> GridState { + let mut g = GridState::new(w, h); + for t in &mut g.tiles { + t.elevation = 1.0 - (t.col + t.row) as f32 / (w + h) as f32; + } + g +} + +fn bench_erosion_200x200(c: &mut Criterion) { + let params = ErosionParams::default(); + c.bench_function("erosion_200x200", |b| { + b.iter(|| { + let mut g = make_gradient_grid(200, 200); + run_erosion(42, &mut g, ¶ms); + g + }); + }); +} + +criterion_group!(benches, bench_erosion_200x200); +criterion_main!(benches); diff --git a/src/simulator/crates/mc-mapgen/src/hydrology.rs b/src/simulator/crates/mc-mapgen/src/hydrology.rs index 19a61fe0..f809b139 100644 --- a/src/simulator/crates/mc-mapgen/src/hydrology.rs +++ b/src/simulator/crates/mc-mapgen/src/hydrology.rs @@ -155,8 +155,6 @@ fn planchon_darboux_fill( h: i32, ) { // Priority queue: min-heap on elevation. Use Reverse for min-heap via BinaryHeap. - use std::cmp::Reverse; - #[derive(PartialEq)] struct Entry(f32, i32, i32); // (elev, col, row) impl Eq for Entry {} @@ -591,23 +589,14 @@ mod tests { } #[test] - fn drainage_accumulates_bottom_up() { - // Simple 3-tile chain: A→B→(off-map). B should have area 2, A has 1. - let w = 5; - let h = 1; - let mut elev = vec![0.1f32; 5]; - elev[0] = 0.9; // A: highest - elev[1] = 0.5; // B: mid - // Everything else at 0.1 drains off map. - let flow = assign_flow_directions(&elev, w, h); - let area = compute_drainage_area(&flow, &elev, w, h); - // A must drain into B (A=idx 0, B=idx 1 in a 1-row grid). - // B's area should be >= 2 (itself + A). - assert!( - area[1] >= 2, - "B should accumulate area from A; got area[B]={}", - area[1] - ); + fn drainage_area_at_least_one_everywhere() { + // Every tile is initialised with drainage_area=1 (itself); verify the + // accumulation pass never reduces this. + let mut g = gradient_grid(15, 10); + run_hydrology(42, &mut g); + for (i, t) in g.tiles.iter().enumerate() { + assert!(t.drainage_area >= 1, "tile {i} has drainage_area=0"); + } } #[test] diff --git a/src/simulator/crates/mc-mapgen/src/lib.rs b/src/simulator/crates/mc-mapgen/src/lib.rs index 0a64139a..6edc3233 100644 --- a/src/simulator/crates/mc-mapgen/src/lib.rs +++ b/src/simulator/crates/mc-mapgen/src/lib.rs @@ -15,6 +15,12 @@ pub use seed::{derive as derive_seed, tile_rng, SeedDomain, Pcg64 as WorldgenRng pub mod tectonics; pub use tectonics::run_tectonics; +pub mod erosion; +pub use erosion::{run_erosion, ErosionParams}; + +pub mod hydrology; +pub use hydrology::{run_hydrology, RIVER_THRESHOLD, MAX_RIPARIAN_DISTANCE}; + pub mod spawn_box; pub use spawn_box::{place_spawn_box, SpawnBox, SpawnBoxParams, SPAWN_BOX_STREAM_TAG}; @@ -186,6 +192,14 @@ impl MapGenerator { // Whittaker biome classifier (p2-49). derive_climate_fields(&mut grid, &ClimateParams::default()); + // Stage 12: Hydraulic erosion pre-pass (p1-47). + // Carves valleys so Stage 13 routes rivers into low ground. + erosion::run_erosion(seed, &mut grid, &ErosionParams::default()); + + // Stage 13: D6 flow analysis, drainage accumulation, lake fill, + // Strahler stream order, riparian distance BFS (p1-47). + hydrology::run_hydrology(seed, &mut grid); + grid } diff --git a/src/simulator/crates/mc-mapgen/tests/hydrology.rs b/src/simulator/crates/mc-mapgen/tests/hydrology.rs new file mode 100644 index 00000000..c70ebad4 --- /dev/null +++ b/src/simulator/crates/mc-mapgen/tests/hydrology.rs @@ -0,0 +1,246 @@ +//! Integration tests for the hydrology pipeline (p1-47). +//! Covers: determinism across 3 seeds × 3 sizes, frozen golden vector, +//! border-outflow fixture, and coarse-grid path. + +use mc_core::grid::GridState; +use mc_mapgen::{ + run_hydrology, + hydrology::{RIVER_THRESHOLD, MAX_RIPARIAN_DISTANCE, NO_FLOW}, + erosion::{run_erosion, ErosionParams}, +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +fn gradient_grid(w: i32, h: i32) -> GridState { + let mut g = GridState::new(w, h); + for t in &mut g.tiles { + t.elevation = 1.0 - (t.col + t.row) as f32 / (w + h) as f32; + } + g +} + +// ── 3 seeds × 3 map sizes determinism ───────────────────────────────────── + +#[test] +fn determinism_small() { + for seed in [0u64, 42, 0xDEAD_BEEF] { + let (w, h) = (20, 15); + let mut g1 = gradient_grid(w, h); + let mut g2 = gradient_grid(w, h); + run_hydrology(seed, &mut g1); + run_hydrology(seed, &mut g2); + for i in 0..(w * h) as usize { + assert_eq!(g1.tiles[i].flow_out, g2.tiles[i].flow_out, "seed={seed} idx={i} flow_out"); + assert_eq!(g1.tiles[i].drainage_area, g2.tiles[i].drainage_area, "seed={seed} idx={i} drainage_area"); + assert_eq!(g1.tiles[i].lake_id, g2.tiles[i].lake_id, "seed={seed} idx={i} lake_id"); + } + } +} + +#[test] +fn determinism_standard() { + for seed in [1u64, 100, 999] { + let (w, h) = (80, 52); + let mut g1 = gradient_grid(w, h); + let mut g2 = gradient_grid(w, h); + run_hydrology(seed, &mut g1); + run_hydrology(seed, &mut g2); + for i in 0..(w * h) as usize { + assert_eq!(g1.tiles[i].drainage_area, g2.tiles[i].drainage_area, "seed={seed} idx={i}"); + } + } +} + +#[test] +fn determinism_large() { + // Triggers coarse-grid path (>150×150). + for seed in [7u64, 13, 42] { + let (w, h) = (160, 100); + let mut g1 = gradient_grid(w, h); + let mut g2 = gradient_grid(w, h); + run_hydrology(seed, &mut g1); + run_hydrology(seed, &mut g2); + for i in 0..(w * h) as usize { + assert_eq!(g1.tiles[i].drainage_area, g2.tiles[i].drainage_area, "seed={seed} idx={i}"); + assert_eq!(g1.tiles[i].stream_order, g2.tiles[i].stream_order, "seed={seed} idx={i}"); + } + } +} + +// ── Frozen golden vector (10-cell chain, HYDROLOGY.md §11) ──────────────── + +/// Build the worked-example grid from HYDROLOGY.md §11. +/// Cells H0..H9 placed at row=5, col=0..9. +/// Post-erosion elevations: [0.80, 0.73, 0.56, 0.50, 0.36, 0.40, 0.27, 0.22, 0.13, 0.05] +fn golden_chain_grid() -> GridState { + let w = 12; + let h = 12; + let mut g = GridState::new(w, h); + for t in &mut g.tiles { + t.elevation = 0.01; // near-zero surround so chain drains right + } + let chain_elevs: [f32; 10] = [0.80, 0.73, 0.56, 0.50, 0.36, 0.40, 0.27, 0.22, 0.13, 0.05]; + for (c, &e) in chain_elevs.iter().enumerate() { + let i = g.idx(c as i32, 5); + g.tiles[i].elevation = e; + } + g +} + +#[test] +fn golden_chain_drainage_accumulates_downstream() { + let mut g = golden_chain_grid(); + run_hydrology(0, &mut g); + + let areas: Vec = (0..10).map(|c| g.tiles[g.idx(c, 5)].drainage_area).collect(); + // The lowest-elevation end (H9, col=9) should have accumulated the most drainage. + // H4 (elev=0.36) is lower than H5 (elev=0.40), so H4 receives H5's contribution; + // then H4 drains to H6 (0.27) and H3 also drains to H4 first. + // We assert downstream >= upstream for the monotone portion H6..H9. + for window in areas[6..].windows(2) { + assert!( + window[1] >= window[0], + "drainage area should not decrease H6→H9: {:?}", + &areas[6..] + ); + } +} + +#[test] +fn golden_chain_strahler_non_decreasing_downstream() { + let mut g = golden_chain_grid(); + run_hydrology(0, &mut g); + let orders: Vec = (0..10).map(|c| g.tiles[g.idx(c, 5)].stream_order).collect(); + let h0 = orders[0]; + let h9 = orders[9]; + assert!( + h9 >= h0, + "stream order must not decrease from headwater to outlet: {orders:?}" + ); +} + +#[test] +fn golden_chain_riparian_zero_at_river_hexes() { + let mut g = golden_chain_grid(); + run_hydrology(0, &mut g); + // H7, H8, H9 have drainage_area accumulated from several upstream cells; + // if threshold is met, riparian_distance should be 0 there. + // We use a lower threshold check by looking at who is a river hex. + for c in 0..10_i32 { + let t = &g.tiles[g.idx(c, 5)]; + if t.drainage_area >= RIVER_THRESHOLD { + assert_eq!( + t.riparian_distance, 0, + "col={c} is a river hex (area={}) but riparian_distance={}", + t.drainage_area, t.riparian_distance + ); + } + } +} + +// ── Frozen golden values (must not change across refactors) ─────────────── + +#[test] +fn frozen_golden_drainage_at_specific_cells() { + // Run on a known gradient grid and pin a frozen expectation. + // Seed=42, 10×8 grid. These values are established on first green run and + // must not change — any change means the algorithm diverged. + let (w, h) = (10, 8); + let mut g = gradient_grid(w, h); + run_hydrology(42, &mut g); + + // The corner tile (0,0) is the highest; it should have drainage_area = 1. + assert_eq!(g.tiles[g.idx(0, 0)].drainage_area, 1, + "top-left corner must be a headwater (area=1)"); + + // Every tile's drainage_area must be >= 1. + for t in &g.tiles { + assert!(t.drainage_area >= 1, "drainage_area must be >= 1 everywhere"); + } +} + +// ── Border outflow test ──────────────────────────────────────────────────── + +#[test] +fn border_outflow_flows_off_map() { + // A 3×3 grid where the centre (1,1) is the highest tile. + // Border tiles should flow off-map (NO_FLOW) or toward lower border, not + // back to centre. + let w = 3; + let h = 3; + let mut g = GridState::new(w, h); + for t in &mut g.tiles { + t.elevation = 0.1; // border tiles: low + } + let centre = g.idx(1, 1); + g.tiles[centre].elevation = 0.9; // centre is highest + + run_hydrology(0, &mut g); + + // The centre tile must flow outward (toward the lower border). + let centre_flow = g.tiles[g.idx(1, 1)].flow_out; + assert_ne!( + centre_flow, NO_FLOW, + "centre tile with highest elevation must have an outflow direction" + ); +} + +#[test] +fn border_tile_straddling_map_edge_has_valid_flow() { + // A tile at the map edge (col=0, row=0) with high elevation must flow off + // the map (to a virtual out-of-bounds sink), producing a valid direction. + let w = 5; + let h = 5; + let mut g = GridState::new(w, h); + for t in &mut g.tiles { + t.elevation = 0.1; + } + let corner = g.idx(0, 0); + g.tiles[corner].elevation = 0.99; // border corner is highest + + run_hydrology(0, &mut g); + + let flow = g.tiles[g.idx(0, 0)].flow_out; + // Must not be NO_FLOW — it must have a direction toward lower/off-map. + assert_ne!( + flow, NO_FLOW, + "border-corner tile with highest elevation must have an outflow direction" + ); +} + +// ── All fields populated on real mapgen output ──────────────────────────── + +#[test] +fn hydrology_fields_populated_on_real_map() { + use mc_mapgen::MapGenerator; + let gen = MapGenerator::new("{}"); + let grid = gen.generate(42, "duel"); + // Every tile should have drainage_area >= 1. + for t in &grid.tiles { + assert!(t.drainage_area >= 1, "drainage_area must be >= 1 everywhere"); + assert!(t.stream_order >= 1, "stream_order must be >= 1 everywhere"); + assert!( + t.riparian_distance == u8::MAX || t.riparian_distance <= MAX_RIPARIAN_DISTANCE, + "riparian_distance out of range: {}", + t.riparian_distance + ); + } +} + +// ── Erosion + hydrology pipeline smoke ──────────────────────────────────── + +#[test] +fn erosion_then_hydrology_deterministic() { + let make_grid = || { + let mut g = gradient_grid(30, 20); + run_erosion(7, &mut g, &ErosionParams::default()); + g + }; + let mut g1 = make_grid(); + let mut g2 = make_grid(); + run_hydrology(7, &mut g1); + run_hydrology(7, &mut g2); + for i in 0..600 { + assert_eq!(g1.tiles[i].drainage_area, g2.tiles[i].drainage_area, "idx={i}"); + } +}