feat(@projects): ✨ implement wasm hydrology bindings
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
31b92bc7d8
commit
8855f69945
10 changed files with 343 additions and 151 deletions
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
src/simulator/crates/mc-mapgen/benches/erosion_bench.rs
Normal file
25
src/simulator/crates/mc-mapgen/benches/erosion_bench.rs
Normal file
|
|
@ -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);
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
246
src/simulator/crates/mc-mapgen/tests/hydrology.rs
Normal file
246
src/simulator/crates/mc-mapgen/tests/hydrology.rs
Normal file
|
|
@ -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<u32> = (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<u8> = (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}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue