feat(@projects/@magic-civilization): ✨ pin deterministic rng across mapgen layers
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
641cc59e15
commit
6b8bda6370
8 changed files with 659 additions and 36 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
public/games/age-of-dwarves/data/tectonics.json
Normal file
16
public/games/age-of-dwarves/data/tectonics.json
Normal 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
|
||||
}
|
||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
543
src/simulator/crates/mc-mapgen/src/tectonics.rs
Normal file
543
src/simulator/crates/mc-mapgen/src/tectonics.rs
Normal 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, ¶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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue