feat(@projects/@magic-civilization): pin deterministic rng across mapgen layers

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 19:36:31 -04:00
parent 641cc59e15
commit 6b8bda6370
8 changed files with 659 additions and 36 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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 ────────────────────────────────────────────────────

View file

@ -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<String> {
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.

View file

@ -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,
}
}
}

View file

@ -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
}

View file

@ -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`.

View file

@ -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<u8> {
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<BoundarySegment> {
let mut segments: Vec<BoundarySegment> = 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<Plate> = 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, &params);
// 8. Proximity fields
compute_proximity_fields(grid, &segments, &params);
// 9. Boundary kind on tiles
assign_tile_boundary_kind(grid, &segments, &params);
}
// ── 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
}
}