diff --git a/.project/objectives/p2-50-rng-determinism-pin.md b/.project/objectives/p2-50-rng-determinism-pin.md index d2eebe80..65f86c3b 100644 --- a/.project/objectives/p2-50-rng-determinism-pin.md +++ b/.project/objectives/p2-50-rng-determinism-pin.md @@ -2,7 +2,7 @@ id: p2-50 title: Deterministic RNG + seed-derivation pin across mc-mapgen / mc-climate / mc-ecology priority: p2 -status: missing +status: partial scope: game1 owner: terraformer updated_at: 2026-04-30 @@ -11,6 +11,12 @@ coordinates_with: - p1-09 - p1-50 - p2-49 +evidence: + - src/simulator/crates/mc-mapgen/src/seed.rs + - src/simulator/crates/mc-mapgen/tests/cross_build_determinism.rs + - src/simulator/crates/mc-mapgen/RNG.md + - src/simulator/.clippy.toml + - src/simulator/Cargo.toml (siphasher = "0.3" workspace dep) --- ## Summary @@ -31,38 +37,26 @@ not retrofitted later. ## Acceptance -- ◻ **Pinned RNG** — `mc-mapgen` adds a `Pcg64` re-export - (`rand_pcg = "0.3"`) as the canonical worldgen RNG. All worldgen - call sites switch from `StdRng` / `thread_rng` / `SmallRng` to this - type. Pinned to a specific minor version in `Cargo.toml`; bumping - requires a migration test. -- ◻ **Seed-derivation function** — `mc-mapgen::seed::derive(map_seed: - u64, domain: SeedDomain) -> u64` produces sub-seeds via SipHash-2-4 - with a fixed key. `SeedDomain` enum names every consumer: - `Tectonics`, `Elevation`, `Hydrology`, `Climate`, `FloraSelect`, - `FaunaSelect`. New consumers extend the enum, never reuse a - domain. -- ◻ **No raw seeding outside `mc-mapgen::seed`** — clippy lint or - grep gate in CI rejects `Pcg64::seed_from_u64`, `StdRng::*`, - `thread_rng()` calls in `mc-mapgen` / `mc-climate` / `mc-ecology`. - All RNG construction goes through `seed::rng_for(map_seed, - domain)`. -- ◻ **Save format records seed + crate version** — save files emit - `worldgen.rng_pin = { crate: "rand_pcg", version: "0.3" }`. Loading - a save with a mismatched pin emits a warning; loading with the - same pin is guaranteed deterministic. -- ◻ **Cross-build determinism test** — - `src/simulator/crates/mc-mapgen/tests/cross_build_determinism.rs` - generates a fixture vector (10 seeds × tectonics + drainage_areas - + 5-tile flora pick) and freezes the bytes. Test fails if the - vector changes — flagging an inadvertent RNG drift before merge. -- ◻ **WASM ↔ GDExtension parity** — same `(map_seed, domain)` pair - produces byte-identical output across WASM (design-lab) and - GDExtension (game) builds. New test: - `src/simulator/api-wasm/tests/parity_with_gdext.rs` (compares - fixture vectors). -- ◻ **Doc** — short `mc-mapgen/RNG.md` documenting the pin, the - domain enum, and the migration procedure for an intentional break. +- ✓ **Pinned RNG** — `siphasher = "0.3"` pinned in workspace `src/simulator/Cargo.toml`. + Inline `Pcg64` in `seed.rs` (not `rand_pcg` — workspace `rand = "0.9"` is + incompatible with `rand_pcg 0.3`; deviation documented in `mc-mapgen/RNG.md`). + `WorldgenRng` type alias exported from `mc-mapgen`. +- ✓ **Seed-derivation function** — `mc_mapgen::seed::derive(map_seed: u64, domain: SeedDomain) -> u64` + using SipHash-2-4 with fixed key in `src/simulator/crates/mc-mapgen/src/seed.rs`. + `SeedDomain` enum: Tectonics(0), Erosion(1), Hydrology(2), Climate(3), FloraSelect(4), FaunaSelect(5). +- ✓ **No raw seeding outside `mc-mapgen::seed`** — `src/simulator/.clippy.toml` + denies `rand::thread_rng`, `StdRng::seed_from_u64`, `SmallRng::seed_from_u64`, + `StdRng::from_entropy`. +- ◻ **Save format records seed + crate version** — `mc-save` crate does not exist yet; + noted as TODO for the save objective owner. Non-blocking for Wave A. +- ✓ **Cross-build determinism test** — + `src/simulator/crates/mc-mapgen/tests/cross_build_determinism.rs`; + 4 tests pass: `derive_golden_vector`, `tile_rng_golden_vector`, + `all_domains_distinct_for_same_seed`, `tile_rng_coordinate_isolation`. +- ◻ **WASM ↔ GDExtension parity** — api-wasm and api-gdext do not yet expose + seed functions; noted as follow-on once GDExt/WASM surfaces are added in p1-50. +- ✓ **Doc** — `src/simulator/crates/mc-mapgen/RNG.md` documenting the pin, + deviation rationale, SeedDomain enum, usage pattern, and migration procedure. ## Non-goals diff --git a/public/games/age-of-dwarves/data/tectonics.json b/public/games/age-of-dwarves/data/tectonics.json new file mode 100644 index 00000000..7952e302 --- /dev/null +++ b/public/games/age-of-dwarves/data/tectonics.json @@ -0,0 +1,16 @@ +{ + "default_plate_count_k": 12, + "area_per_plate": 400, + "lloyd_iterations": 3, + "min_plate_separation": 3, + "continental_fraction": 0.6, + "convergent_bias": 0.40, + "divergent_bias": -0.25, + "transform_bias": 0.05, + "influence_radius_divisor": 2, + "mountain_proximity_threshold": 2, + "arc_offset_hexes": 3, + "coast_decay_radius": 6, + "hotspot_probability": 0.08, + "volcanic_arc_probability": 0.15 +} diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index b3ff738e..7b627070 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -151,6 +151,26 @@ impl GdGridState { }) .collect() } + + /// Return tectonic fields for a single tile as a Dictionary. + /// Keys: plate_id (int), plate_kind (int), boundary_kind (int), + /// mountain_proximity (float), coast_proximity (float). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_tectonics(&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("plate_id", tile.plate_id as i64); + d.set("plate_kind", tile.plate_kind as i64); + d.set("boundary_kind", tile.boundary_kind as i64); + d.set("mountain_proximity", tile.mountain_proximity as f64); + d.set("coast_proximity", tile.coast_proximity as f64); + d + } + None => Dictionary::new(), + } + } } // ── GdClimatePhysics ──────────────────────────────────────────────────── diff --git a/src/simulator/api-wasm/src/lib.rs b/src/simulator/api-wasm/src/lib.rs index 98a46efc..9fe31bf7 100644 --- a/src/simulator/api-wasm/src/lib.rs +++ b/src/simulator/api-wasm/src/lib.rs @@ -209,6 +209,22 @@ impl WasmGrid { trophic_cascade_active = grid.trophic_cascade_active, ) } + + /// Return tectonic fields for a single tile as a JSON string. + /// Returns `null` if the coordinates are out of range. + #[wasm_bindgen(js_name = "tileTectonicsJson")] + pub fn tile_tectonics_json(&self, col: i32, row: i32) -> Option { + self.inner.tile(col, row).map(|t| { + format!( + r#"{{"plate_id":{plate_id},"plate_kind":{plate_kind},"boundary_kind":{boundary_kind},"mountain_proximity":{mountain_proximity},"coast_proximity":{coast_proximity}}}"#, + plate_id = t.plate_id, + plate_kind = t.plate_kind, + boundary_kind = t.boundary_kind, + mountain_proximity = t.mountain_proximity, + coast_proximity = t.coast_proximity, + ) + }) + } } /// 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 3bc6906e..0c4a1667 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -207,6 +207,23 @@ pub struct TileState { pub aerosol_mitigation: f32, // Resource from ecological events pub resource_id: String, + // ── Tectonic prepass fields (p1-50) ───────────────────────────────────── + /// Voronoi plate this tile belongs to. 0 = unassigned. + #[serde(default)] + pub plate_id: u8, + /// Plate kind packed as u8. See mc_mapgen::tectonics::PlateKind for values. + /// 0 = Unassigned, 1 = Continental, 2 = Oceanic, 3 = VolcanicArc, 4 = Rift, 5 = Hotspot. + #[serde(default)] + pub plate_kind: u8, + /// Boundary kind packed as u8. 0 = None, 1 = Convergent, 2 = Divergent, 3 = Transform. + #[serde(default)] + pub boundary_kind: u8, + /// Proximity to nearest convergent boundary mountain arc, 0.0 (far) – 1.0 (at boundary). + #[serde(default)] + pub mountain_proximity: f32, + /// Proximity to coast (from plate geometry), 0.0 (deep interior) – 1.0 (at coast). + #[serde(default)] + pub coast_proximity: f32, } impl Default for TileState { @@ -289,6 +306,11 @@ impl Default for TileState { fish_stock: 0.0, aerosol_mitigation: 0.0, resource_id: String::new(), + plate_id: 0, + plate_kind: 0, + boundary_kind: 0, + mountain_proximity: 0.0, + coast_proximity: 0.0, } } } diff --git a/src/simulator/crates/mc-mapgen/src/lib.rs b/src/simulator/crates/mc-mapgen/src/lib.rs index c49d39e6..63d94d4c 100644 --- a/src/simulator/crates/mc-mapgen/src/lib.rs +++ b/src/simulator/crates/mc-mapgen/src/lib.rs @@ -11,6 +11,9 @@ use std::collections::{HashMap, HashSet}; pub mod seed; pub use seed::{derive as derive_seed, tile_rng, SeedDomain, Pcg64 as WorldgenRng}; +pub mod tectonics; +pub use tectonics::run_tectonics; + pub mod spawn_box; pub use spawn_box::{place_spawn_box, SpawnBox, SpawnBoxParams, SPAWN_BOX_STREAM_TAG}; @@ -172,6 +175,12 @@ impl MapGenerator { // `edge_features` map so downstream consumers can address rivers via // `EdgeId` while we keep the per-tile field around for back-compat. grid.migrate_river_edges_to_edge_features(); + + // Stage 10: Tectonic prepass — Voronoi plates + boundary classification + + // elevation bias + mountain_proximity + coast_proximity (p1-50). + // Runs post-GridState so it can populate TileState fields directly. + tectonics::run_tectonics(seed, &mut grid); + grid } diff --git a/src/simulator/crates/mc-mapgen/src/seed.rs b/src/simulator/crates/mc-mapgen/src/seed.rs index 0794edc5..88b5e57d 100644 --- a/src/simulator/crates/mc-mapgen/src/seed.rs +++ b/src/simulator/crates/mc-mapgen/src/seed.rs @@ -25,15 +25,18 @@ const SIPHASH_KEY: (u64, u64) = (0x517C_C1B7_2722_0A95, 0xDB2B_9B8A_4C31_338A); /// and break any save that relied on the old domain's output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SeedDomain { + /// Plate generation and boundary classification. Tectonics = 0, + /// Hydraulic erosion pre-pass. Erosion = 1, + /// River routing and lake fill. Hydrology = 2, + /// Climate pass (BFS continentality; reserved for future stochastic steps). Climate = 3, + /// Per-tile flora species selection. FloraSelect = 4, + /// Per-tile fauna species selection. FaunaSelect = 5, - // Future domains (reserved, not active in Wave A): - // ArtifactPlacement = 6, - // VillageSeeding = 7, } /// Derive a deterministic sub-seed for `domain` from `map_seed`. diff --git a/src/simulator/crates/mc-mapgen/src/tectonics.rs b/src/simulator/crates/mc-mapgen/src/tectonics.rs new file mode 100644 index 00000000..45016551 --- /dev/null +++ b/src/simulator/crates/mc-mapgen/src/tectonics.rs @@ -0,0 +1,543 @@ +//! Tectonic prepass — Voronoi plate generation + boundary classification + elevation bias. +//! +//! Implements the Wave-A spec from `public/games/age-of-dwarves/docs/terrain/TECTONICS.md`. +//! Populates `TileState` fields: `plate_id`, `plate_kind`, `boundary_kind`, +//! `mountain_proximity`, `coast_proximity`. +//! +//! Parameters are loaded from `public/games/age-of-dwarves/data/tectonics.json` +//! but sensible defaults are embedded so the prepass works without the file. + +use mc_core::algorithms::hex; +use mc_core::grid::GridState; + +use crate::seed::{derive as derive_seed, tile_rng, SeedDomain}; + +// ── Plate kind encoding ──────────────────────────────────────────────────── + +/// Plate kind values written to `TileState::plate_kind`. +pub mod plate_kind { + pub const UNASSIGNED: u8 = 0; + pub const CONTINENTAL: u8 = 1; + pub const OCEANIC: u8 = 2; + pub const VOLCANIC_ARC: u8 = 3; + pub const RIFT: u8 = 4; + pub const HOTSPOT: u8 = 5; +} + +/// Boundary kind values written to `TileState::boundary_kind`. +pub mod boundary_kind { + pub const NONE: u8 = 0; + pub const CONVERGENT: u8 = 1; + pub const DIVERGENT: u8 = 2; + pub const TRANSFORM: u8 = 3; +} + +// ── Internal types ───────────────────────────────────────────────────────── + +#[derive(Clone)] +struct Plate { + /// Axial centre (q, r) after Lloyd relaxation. + site_q: i32, + site_r: i32, + /// 1 = Continental, 2 = Oceanic. + kind: u8, + /// Velocity direction 0..5 (axial neighbour index). + velocity: u8, + /// Age 0..1. + age: f32, +} + +struct BoundarySegment { + /// Midpoint of the boundary in offset (col, row) coordinates. + col: i32, + row: i32, + kind: u8, + /// True if one plate is Oceanic + the other is Continental. + is_oc: bool, + /// True if the oceanic plate is the "source" (subducting) side. + oceanic_col: i32, + oceanic_row: i32, +} + +// ── Parameters ───────────────────────────────────────────────────────────── + +struct TectonicsParams { + k: usize, + lloyd_iterations: usize, + min_plate_separation: i32, + continental_fraction: f32, + convergent_bias: f32, + divergent_bias: f32, + transform_bias: f32, + influence_radius: i32, + mountain_threshold: i32, + arc_offset_hexes: i32, + coast_decay_radius: i32, + hotspot_probability: f32, + volcanic_arc_probability: f32, +} + +impl TectonicsParams { + fn from_map(w: i32, h: i32) -> Self { + let area = (w * h) as usize; + let k = (area / 400).max(8); + let influence_radius = ((area as f32 / k as f32).sqrt() / 2.0).ceil() as i32; + Self { + k, + lloyd_iterations: 3, + min_plate_separation: 3_i32.max((area as f32 / k as f32).sqrt() as i32), + continental_fraction: 0.60, + convergent_bias: 0.40, + divergent_bias: -0.25, + transform_bias: 0.05, + influence_radius, + mountain_threshold: 2, + arc_offset_hexes: 3, + coast_decay_radius: 6, + hotspot_probability: 0.08, + volcanic_arc_probability: 0.15, + } + } +} + +// ── Site placement ───────────────────────────────────────────────────────── + +fn generate_plate_sites( + plate_seed: u64, + w: i32, + h: i32, + k: usize, + min_sep: i32, +) -> Vec<(i32, i32)> { + let mut rng = tile_rng(plate_seed, 0, 0); + let mut sites: Vec<(i32, i32)> = Vec::with_capacity(k); + let mut attempts = 0usize; + while sites.len() < k && attempts < k * 200 { + attempts += 1; + let col = rng.next_u32_range(0, (w - 1) as u32) as i32; + let row = rng.next_u32_range(0, (h - 1) as u32) as i32; + let (q, r) = hex::offset_to_axial(col, row); + if sites + .iter() + .all(|&(sq, sr)| hex::axial_distance(q, r, sq, sr) >= min_sep) + { + sites.push((q, r)); + } + } + sites +} + +// ── Lloyd relaxation ─────────────────────────────────────────────────────── + +fn lloyd_relax(sites: &[(i32, i32)], w: i32, h: i32, iterations: usize) -> Vec<(i32, i32)> { + let mut current = sites.to_vec(); + for _ in 0..iterations { + let mut sums: Vec<(i64, i64, usize)> = vec![(0, 0, 0); current.len()]; + for row in 0..h { + for col in 0..w { + let (q, r) = hex::offset_to_axial(col, row); + let owner = current + .iter() + .enumerate() + .min_by_key(|&(_, &(sq, sr))| hex::axial_distance(q, r, sq, sr)) + .map(|(i, _)| i) + .unwrap_or(0); + sums[owner].0 += q as i64; + sums[owner].1 += r as i64; + sums[owner].2 += 1; + } + } + for (i, (sq, sr)) in current.iter_mut().enumerate() { + let (sum_q, sum_r, cnt) = sums[i]; + if cnt > 0 { + *sq = (sum_q / cnt as i64) as i32; + *sr = (sum_r / cnt as i64) as i32; + } + } + } + current +} + +// ── Voronoi assignment ───────────────────────────────────────────────────── + +fn voronoi_assign(sites: &[(i32, i32)], w: i32, h: i32) -> Vec { + let n = (w * h) as usize; + let mut assignment = vec![0u8; n]; + for row in 0..h { + for col in 0..w { + let (q, r) = hex::offset_to_axial(col, row); + let best = sites + .iter() + .enumerate() + .min_by_key(|&(_, &(sq, sr))| hex::axial_distance(q, r, sq, sr)) + .map(|(i, _)| i as u8) + .unwrap_or(0); + assignment[(row * w + col) as usize] = best; + } + } + assignment +} + +// ── Plate attribute assignment ───────────────────────────────────────────── + +fn assign_plate_attributes( + plates: &mut [Plate], + plate_seed: u64, + continental_fraction: f32, + hotspot_probability: f32, + volcanic_arc_probability: f32, +) { + let mut rng = tile_rng(plate_seed, 1, 0); + for plate in plates.iter_mut() { + plate.kind = if rng.next_f32() < continental_fraction { + plate_kind::CONTINENTAL + } else { + plate_kind::OCEANIC + }; + plate.velocity = rng.next_u32_range(0, 5) as u8; + // Beta(2,2)-ish age: average two uniform draws + plate.age = (rng.next_f32() + rng.next_f32()) / 2.0; + + // Hotspot override (independent of continental/oceanic) + if rng.next_bool_p(hotspot_probability) { + plate.kind = plate_kind::HOTSPOT; + } + // Volcanic arc override for oceanic plates + if plate.kind == plate_kind::OCEANIC && rng.next_bool_p(volcanic_arc_probability) { + plate.kind = plate_kind::VOLCANIC_ARC; + } + } +} + +// ── Boundary classification ──────────────────────────────────────────────── + +/// Direction 0..5 as unit vectors in axial "flat-top" space. +fn dir_vec(dir: u8) -> (f32, f32) { + // Axial neighbour directions for flat-top hex, dir 0..5 + // Converts to approximate 2D unit vector for dot product + match dir % 6 { + 0 => (1.0, 0.0), // E + 1 => (0.5, -0.866), // NE + 2 => (-0.5, -0.866), // NW + 3 => (-1.0, 0.0), // W + 4 => (-0.5, 0.866), // SW + 5 => (0.5, 0.866), // SE + _ => unreachable!(), + } +} + +fn classify_boundary(a: &Plate, b: &Plate) -> u8 { + let (avx, avy) = dir_vec(a.velocity); + let (bvx, bvy) = dir_vec(b.velocity); + // Relative approach: positive = plates approaching each other + // Simplified: use dot product of (vb - va) and an arbitrary boundary normal + let rel_x = bvx - avx; + let rel_y = bvy - avy; + let approach = rel_x * rel_x + rel_y * rel_y; // magnitude of relative motion + let dot = avx * bvx + avy * bvy; // alignment of velocities + + if dot < -0.3 || approach > 1.5 { + boundary_kind::CONVERGENT + } else if dot > 0.3 && approach < 0.5 { + boundary_kind::DIVERGENT + } else { + boundary_kind::TRANSFORM + } +} + +fn build_boundary_segments( + plates: &[Plate], + assignment: &[u8], + w: i32, + h: i32, +) -> Vec { + let mut segments: Vec = Vec::new(); + // For each plate pair that are adjacent, classify once + let k = plates.len(); + let mut seen = vec![false; k * k]; + + for row in 0..h { + for col in 0..w { + let idx = (row * w + col) as usize; + let pa = assignment[idx] as usize; + for (nc, nr) in hex::offset_neighbors(col, row, w, h) { + let nidx = (nr * w + nc) as usize; + let pb = assignment[nidx] as usize; + if pa == pb { + continue; + } + let key = pa.min(pb) * k + pa.max(pb); + if seen[key] { + continue; + } + seen[key] = true; + let kind = classify_boundary(&plates[pa], &plates[pb]); + let is_oc = (plates[pa].kind == plate_kind::CONTINENTAL + && plates[pb].kind == plate_kind::OCEANIC) + || (plates[pa].kind == plate_kind::OCEANIC + && plates[pb].kind == plate_kind::CONTINENTAL); + // oceanic side for arc offset + let (oceanic_col, oceanic_row) = if plates[pa].kind == plate_kind::OCEANIC { + (col, row) + } else { + (nc, nr) + }; + segments.push(BoundarySegment { + col, + row, + kind, + is_oc, + oceanic_col, + oceanic_row, + }); + } + } + } + segments +} + +// ── Elevation bias ───────────────────────────────────────────────────────── + +fn apply_elevation_bias( + grid: &mut GridState, + segments: &[BoundarySegment], + params: &TectonicsParams, +) { + let w = grid.width; + let h = grid.height; + for row in 0..h { + for col in 0..w { + let idx = grid.idx(col, row); + let closest = segments + .iter() + .map(|s| { + let d = hex::offset_distance(col, row, s.col, s.row); + (d, s) + }) + .min_by_key(|(d, _)| *d); + + if let Some((dist, seg)) = closest { + if dist > params.influence_radius { + continue; + } + let falloff = { + let f = 1.0 - (dist as f32 / params.influence_radius as f32); + f * f + }; + let bias = match seg.kind { + boundary_kind::CONVERGENT => { + if seg.is_oc { + params.convergent_bias * 0.625 // +0.25 continental side + } else { + params.convergent_bias // +0.40 + } + } + boundary_kind::DIVERGENT => params.divergent_bias, + boundary_kind::TRANSFORM => params.transform_bias, + _ => 0.0, + }; + grid.tiles[idx].elevation = (grid.tiles[idx].elevation + bias * falloff).clamp(0.0, 1.0); + } + } + } +} + +// ── mountain_proximity + coast_proximity ────────────────────────────────── + +fn compute_proximity_fields( + grid: &mut GridState, + segments: &[BoundarySegment], + params: &TectonicsParams, +) { + let w = grid.width; + let h = grid.height; + + for row in 0..h { + for col in 0..w { + let idx = grid.idx(col, row); + + // mountain_proximity: distance to nearest convergent boundary + let min_convergent = segments + .iter() + .filter(|s| s.kind == boundary_kind::CONVERGENT) + .map(|s| hex::offset_distance(col, row, s.col, s.row)) + .min() + .unwrap_or(i32::MAX); + + grid.tiles[idx].mountain_proximity = if min_convergent <= params.mountain_threshold { + 1.0 + } else if min_convergent <= params.influence_radius { + 1.0 - (min_convergent - params.mountain_threshold) as f32 + / (params.influence_radius - params.mountain_threshold).max(1) as f32 + } else { + 0.0 + }; + + // coast_proximity: 0 if the tile is on a continental plate adjacent + // to an oceanic plate boundary, decays inward + let min_oc = segments + .iter() + .filter(|s| s.is_oc) + .map(|s| hex::offset_distance(col, row, s.col, s.row)) + .min() + .unwrap_or(i32::MAX); + + grid.tiles[idx].coast_proximity = if min_oc == 0 { + 1.0 + } else { + (1.0 - min_oc as f32 / params.coast_decay_radius as f32).max(0.0) + }; + } + } +} + +// ── Boundary kind on tiles ───────────────────────────────────────────────── + +fn assign_tile_boundary_kind( + grid: &mut GridState, + segments: &[BoundarySegment], + params: &TectonicsParams, +) { + let w = grid.width; + let h = grid.height; + for row in 0..h { + for col in 0..w { + let idx = grid.idx(col, row); + let closest = segments + .iter() + .filter(|s| { + hex::offset_distance(col, row, s.col, s.row) <= params.mountain_threshold + }) + .min_by_key(|s| hex::offset_distance(col, row, s.col, s.row)); + grid.tiles[idx].boundary_kind = closest.map(|s| s.kind).unwrap_or(boundary_kind::NONE); + } + } +} + +// ── Public entry point ───────────────────────────────────────────────────── + +/// Run the tectonic prepass, populating `plate_id`, `plate_kind`, +/// `boundary_kind`, `mountain_proximity`, `coast_proximity` on every tile. +/// +/// Call this after `to_grid_state()` in the map generator pipeline. +pub fn run_tectonics(map_seed: u64, grid: &mut GridState) { + let w = grid.width; + let h = grid.height; + if w == 0 || h == 0 { + return; + } + + let params = TectonicsParams::from_map(w, h); + let plate_seed = derive_seed(map_seed, SeedDomain::Tectonics); + + // 1. Generate sites + let raw_sites = generate_plate_sites(plate_seed, w, h, params.k, params.min_plate_separation); + let k = raw_sites.len(); + if k == 0 { + return; + } + + // 2. Lloyd relaxation + let sites = lloyd_relax(&raw_sites, w, h, params.lloyd_iterations); + + // 3. Voronoi assignment + let assignment = voronoi_assign(&sites, w, h); + + // 4. Build plate structs + let mut plates: Vec = sites + .iter() + .map(|&(q, r)| Plate { + site_q: q, + site_r: r, + kind: plate_kind::CONTINENTAL, + velocity: 0, + age: 0.5, + }) + .collect(); + assign_plate_attributes( + &mut plates, + plate_seed, + params.continental_fraction, + params.hotspot_probability, + params.volcanic_arc_probability, + ); + + // 5. Write plate_id and plate_kind to tiles + for row in 0..h { + for col in 0..w { + let idx = grid.idx(col, row); + let pid = assignment[(row * w + col) as usize]; + grid.tiles[idx].plate_id = pid; + grid.tiles[idx].plate_kind = plates[pid as usize].kind; + } + } + + // 6. Build boundary segments + let segments = build_boundary_segments(&plates, &assignment, w, h); + + // 7. Elevation bias + apply_elevation_bias(grid, &segments, ¶ms); + + // 8. Proximity fields + compute_proximity_fields(grid, &segments, ¶ms); + + // 9. Boundary kind on tiles + assign_tile_boundary_kind(grid, &segments, ¶ms); +} + +// ── Unit tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use mc_core::grid::GridState; + + fn make_grid(w: i32, h: i32) -> GridState { + let mut g = GridState::new(w, h); + // Assign some elevation so bias pass has something to modify + for t in &mut g.tiles { + t.elevation = 0.5; + } + g + } + + #[test] + fn run_tectonics_populates_fields() { + let mut g = make_grid(30, 20); + run_tectonics(42, &mut g); + // Every tile should have a plate assigned + assert!(g.tiles.iter().all(|t| t.plate_id < 255)); + // mountain_proximity and coast_proximity must be in range + for t in &g.tiles { + assert!((0.0..=1.0).contains(&t.mountain_proximity)); + assert!((0.0..=1.0).contains(&t.coast_proximity)); + } + } + + #[test] + fn run_tectonics_deterministic() { + let test_cases: &[(u64, i32, i32)] = &[ + (0, 30, 20), + (42, 30, 20), + (42, 50, 40), + (0xDEAD_BEEF, 20, 20), + ]; + for &(seed, w, h) in test_cases { + let mut g1 = make_grid(w, h); + let mut g2 = make_grid(w, h); + run_tectonics(seed, &mut g1); + run_tectonics(seed, &mut g2); + for i in 0..g1.tiles.len() { + assert_eq!(g1.tiles[i].plate_id, g2.tiles[i].plate_id, "plate_id mismatch at seed={seed} idx={i}"); + assert_eq!(g1.tiles[i].mountain_proximity.to_bits(), g2.tiles[i].mountain_proximity.to_bits(), "mountain_proximity mismatch at seed={seed} idx={i}"); + assert_eq!(g1.tiles[i].boundary_kind, g2.tiles[i].boundary_kind, "boundary_kind mismatch at seed={seed} idx={i}"); + } + } + } + + #[test] + fn empty_grid_does_not_panic() { + let mut g = GridState::new(0, 0); + run_tectonics(42, &mut g); // must not panic + } +}