Compare commits
8
Commits
20ed153991
...
5c4a3fda19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4a3fda19 | ||
|
|
a61d030b47 | ||
|
|
b4eff847fc | ||
|
|
aaee9ecb06 | ||
|
|
9bc2ed28f2 | ||
|
|
41343987f0 | ||
|
|
de9142ce37 | ||
|
|
f4f43e826c |
@@ -19,6 +19,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- **Gemma naming methodology doc** (`docs/gemma-naming-methodology.md`) — preserves the corridor-aware LLM place-naming approach (sector palettes, few-shot prompting, dedup, Earth-major blocklist, deterministic fallback) as institutional knowledge after the naming pipeline's retirement
|
||||
- **Layer-1 world topography cascade** (#953, D-208/209/210) — deterministic empty-world generation in Rust: D8 priority-flood drainage (rivers/basins, ~45 ms/body after optimization), 7 geographic settlement-attractor tags, and sub-biome + terrain-modification-cost classification. The foundation the settlement-placement layer builds on
|
||||
- **Canonical 16-bit heightmaps** (#963, D-202) — every body's elevation is now a per-body 16-bit grayscale `heightmap.png` (1024×512, `sea_level` in a tEXt chunk); the old color render is renamed `reliefmap.png`. Single source of truth the generation cascade reads
|
||||
- **Deterministic generation cascade** (#952, D-224/D-200) — `SeedChain`, a single splitmix64-based seed-derivation contract descending from the master world seed with domain separation (replaces ad-hoc seed mixing); an extensible per-layer cascade harness (`run_cascade`); and a golden-seed regression test pinning Layer 0→1 output (source heightmap SHA-256 + serialized topography) so any determinism drift is caught
|
||||
|
||||
### Changed
|
||||
- `teammateMode` set to `auto` — teammates spawn in tmux split panes when the lead runs inside tmux, with graceful in-process fallback
|
||||
|
||||
@@ -209,8 +209,9 @@ golden-diff:
|
||||
fi
|
||||
|
||||
golden-update:
|
||||
@echo "Regenerating golden file..."
|
||||
@echo "Regenerating golden files..."
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test cascade_golden
|
||||
@git add server/tests/golden/
|
||||
@echo "--- Golden file updated and staged ---"
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
@@ -1444,4 +1444,41 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*83 decisions (D-001 through D-223, excluding gaps). Last updated: 2026-05-22 (D-223 — Authored content as a flavored name pool)*
|
||||
### D-224: SeedChain — deterministic seed-derivation contract
|
||||
- **Date:** 2026-05-23
|
||||
- **Decision:** All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, **`SeedChain`**, living at **`server/src/seed.rs`** (top-level, because seeds are broader than the atlas). It is the **only** sanctioned way to derive a child seed; the ad-hoc pre-mixing (`seed.wrapping_add(C)`) that was in `skeleton_gen.rs` / `district_mix.rs` is removed, and the doc-comment references to a "SeedChain" in those files become real.
|
||||
|
||||
**Mixing primitive.** `splitmix64` — already used by `EntityRng::from_seed_and_id` (`simulation/rng.rs`), chosen there specifically to avoid the `wrapping_add` collision class where `(seed=0,id=N) == (seed=1,id=N-1)` — is promoted to a shared `pub(crate)` function in `server/src/seed.rs` and reused. One canonical *mixer* for the whole codebase. (`EntityRng` keeps its own domainless combine — `splitmix64(world_seed) ^ splitmix64(stable_id)` — for now; re-expressing it as `derive(Npc, stable_id)` would be a deliberate, **stream-changing** migration, not a free cleanup, so it is left as a follow-up.)
|
||||
|
||||
**Contract:**
|
||||
```rust
|
||||
pub struct SeedChain(u64); // Copy
|
||||
pub enum SeedDomain { Body, Layer1Topography, Layer3Settlement, Layer4Quarter, Block, Npc /* … */ } // u64 tags
|
||||
impl SeedChain {
|
||||
pub fn root(world_seed: u64) -> Self; // top of the chain
|
||||
pub fn for_body(world_seed: u64, body_id: &str) -> Self; // root + derive(Body, fnv1a_64(id))
|
||||
pub fn derive(self, domain: SeedDomain, id: u64) -> Self;
|
||||
pub fn atlas_rng(self) -> AtlasRng; // integer-only LCG stream (D-010)
|
||||
pub fn seed(self) -> u64; // raw — for SimRng/ChaCha or further derive()
|
||||
}
|
||||
```
|
||||
|
||||
**Derivation (load-bearing — pinned, because changing it changes every generated world):**
|
||||
`derive(domain, id) = splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id)`.
|
||||
Properties: deterministic; domain-separated (distinct `SeedDomain` tags never share a stream); full avalanche (splitmix64 on each input); integer-only (D-010 #4); chainable (`root → Body → Layer3Settlement → Quarter → Block`). The output is well-distributed, so `AtlasRng::new` is fed the derived seed directly — no `| 1` or golden-ratio pre-mix guard.
|
||||
|
||||
**Body identity (the `Body` domain id).** Bodies are identified by a *string* `body_id`, not a numeric StableId, so `SeedDomain::Body` is keyed by **FNV-1a (64-bit) of `body_id`** — the repo's standard deterministic `&str → u64` convention (matching `TemplateId`/`TriangleId`). `SeedChain::for_body(world_seed, body_id)` (`root(world_seed).derive(Body, fnv1a_64(body_id))`) is the single sanctioned path; callers must use it rather than inventing their own string→u64 hash, or they would silently derive divergent worlds from the same seed — the very class of nondeterminism this record exists to kill, one level up.
|
||||
|
||||
**Stability guards.** `SeedDomain` carries explicit `#[repr(u64)]` discriminants and is append-only; the unit test `seed_domain_discriminants_are_pinned` fails CI if any is renumbered (which would re-roll every world). `AttractorType` likewise carries explicit `#[repr(u8)]` discriminants because it is cast `as u8` as a sort key (`features.rs`); reordering it would change attractor ordering and flip the cascade golden.
|
||||
|
||||
**Scope of effect (verified 2026-05-23):** SeedChain changes only the RNG-*using* layers — the existing `skeleton_gen.rs` (Layer 4 block placement) and the future Layer-3 settlement placement (#955). It does **not** affect Layer 0 heightmaps (produced by the Python `planet_simulation` pipeline, seeded separately via `--seed`, committed as `heightmap.png` files) nor Layer 1 (`drainage`/`features`/`subbiome` are RNG-free — pure functions of the heightmap). The #952 Layer 0→1 golden fixtures are therefore SeedChain-independent and can be captured in any order relative to the SeedChain work.
|
||||
|
||||
- **Rationale:** Three seeding paths had drifted apart — `AtlasRng` (LCG, "callers pre-mix"), `EntityRng` (correct splitmix64 mixing), and ad-hoc `wrapping_add` in atlas callers — while the code already *named* a SeedChain that didn't exist. A single typed derivation chain with domain separation makes every sub-stream reproducible from one world seed, eliminates the `(seed,id)` collision class `wrapping_add` invites, and gives the determinism harness (#952) a stable contract to verify against. Promoting one mixer prevents two divergent implementations.
|
||||
- **Implementation:** #952 (Phase 4, epic #750) — `server/src/seed.rs` (incl. `for_body`/`fnv1a_64`), `SeedChain` threaded through the atlas RNG callers, an extensible cascade harness (`run_cascade`/`CascadeSnapshot`/`CascadeLayer`), and a golden-seed regression test (SHA-256 of `heightmap.png` + JSON-serialized `Layer1Output`, run at 256×128 so the river network is non-empty — JSON not msgpack, to match the diffable `golden_suite.rs` convention). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's `schema_version` lineage covers future changes once saves exist.
|
||||
- **Raised by:** Jeroen + Claude, `/whats-next` refinement of #952, 2026-05-23.
|
||||
- **Cross-reference:** [D-010](#d-010) (determinism — integer-only, seed→identical output), [D-200](#d-200) (three-tier execution model), [D-208](#d-208) (RNG-free drainage), [D-223](#d-223) (names-only pool — placement uses seeded RNG), `simulation/rng.rs` (EntityRng / splitmix64 precedent)
|
||||
- **Dissent:** None
|
||||
|
||||
---
|
||||
|
||||
*84 decisions (D-001 through D-224, excluding gaps). Last updated: 2026-05-23 (D-224 — SeedChain seed-derivation contract)*
|
||||
|
||||
Generated
+66
@@ -352,6 +352,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -480,6 +489,15 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -538,6 +556,16 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
@@ -584,6 +612,16 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@@ -776,6 +814,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1443,6 +1491,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_norway",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"thiserror",
|
||||
"toml",
|
||||
@@ -1450,6 +1499,17 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1727,6 +1787,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -42,6 +42,8 @@ gauntlet = []
|
||||
# Maintained drop-in fork of the deprecated serde_yaml 0.9 (#966). Test-only:
|
||||
# the YAML round-trip in poi.rs and the trait-config fixture in trait_modifiers.rs.
|
||||
serde_norway = "0.9"
|
||||
# SHA-256 of the source heightmap.png in the cascade golden test (#952).
|
||||
sha2 = "0.10"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit test target for the Layer 3 integration module (D-030, ticket #200).
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
@@ -26,7 +27,7 @@ pub const CACHE_CAPACITY: usize = 50;
|
||||
|
||||
/// River network extracted by the D8 drainage algorithm (D-208).
|
||||
/// Stub — replaced when #918 is implemented.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RiverNetwork {
|
||||
/// Pixel positions (row, col) of all river cells (flow_accumulation > 200).
|
||||
pub river_cells: Vec<(u16, u16)>,
|
||||
@@ -38,7 +39,7 @@ pub struct RiverNetwork {
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
/// Stub — boundary polyline data comes from atlas_province_boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DrainageBasin {
|
||||
pub basin_id: u32,
|
||||
/// Boundary polyline as pixel-space (row, col) points.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Generation cascade harness (#952, D-200).
|
||||
//!
|
||||
//! [`run_cascade`] runs the deterministic generation cascade for one body, from
|
||||
//! Layer 0 (load the baked `heightmap.png`, D-202) up to a requested layer, and
|
||||
//! returns a [`CascadeSnapshot`]. The harness is extensible: each new layer is
|
||||
//! added to [`CascadeLayer`] and populated on the snapshot as it lands (#954+).
|
||||
//! The golden-seed regression test (#952) diffs a snapshot against a stored
|
||||
//! fixture.
|
||||
//!
|
||||
//! [`run_cascade_from_heightmap`] is the pure, in-memory core (no file I/O); the
|
||||
//! path-loading [`run_cascade`] is a thin wrapper around it.
|
||||
//!
|
||||
//! **Determinism (D-010 #4):** for a fixed heightmap + [`SeedChain`], the
|
||||
//! snapshot is reproducible. Layers 0–1 are RNG-free (pure functions of the
|
||||
//! heightmap); the carried `SeedChain` feeds the RNG-using layers (Layer 3+,
|
||||
//! D-224).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::seed::SeedChain;
|
||||
|
||||
/// Cascade layers in execution order (D-200). [`run_cascade`] runs every layer
|
||||
/// up to and including the requested one. Append new layers as they are built;
|
||||
/// the `Ord` derive relies on declaration order, so only ever append.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum CascadeLayer {
|
||||
/// Layer 0 — load the pre-baked 16-bit `heightmap.png` (D-202).
|
||||
Heightmap,
|
||||
/// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953).
|
||||
Topography,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
///
|
||||
/// Extensible: each layer's artifact is an `Option` that becomes `Some` once
|
||||
/// that layer has run. Layer 0 (`heightmap`) is always present.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CascadeSnapshot {
|
||||
pub body_id: String,
|
||||
/// This body's root in the deterministic seed tree (D-224). Unused by the
|
||||
/// RNG-free Layers 0–1; carried for the RNG-using layers (Layer 3+).
|
||||
pub seed: SeedChain,
|
||||
/// Layer 0 — the loaded heightmap.
|
||||
pub heightmap: BodyHeightmap,
|
||||
/// Layer 1 — topography. `Some` once [`CascadeLayer::Topography`] has run.
|
||||
pub layer1: Option<Layer1Output>,
|
||||
}
|
||||
|
||||
/// Run the cascade from a heightmap already in memory, up to `up_to`.
|
||||
///
|
||||
/// Pure (no I/O); this is the testable core. `body_seed` is this body's
|
||||
/// [`SeedChain`] position — the caller derives it from the world seed via
|
||||
/// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`.
|
||||
pub fn run_cascade_from_heightmap(
|
||||
body_seed: SeedChain,
|
||||
heightmap: BodyHeightmap,
|
||||
up_to: CascadeLayer,
|
||||
) -> CascadeSnapshot {
|
||||
let mut snapshot = CascadeSnapshot {
|
||||
body_id: heightmap.body_id.clone(),
|
||||
seed: body_seed,
|
||||
heightmap,
|
||||
layer1: None,
|
||||
};
|
||||
|
||||
// Layer 1 — topography (RNG-free; pure function of the heightmap).
|
||||
if up_to >= CascadeLayer::Topography {
|
||||
snapshot.layer1 = Some(layer1::run_layer1(&snapshot.heightmap));
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Run the cascade for one body, loading its baked `heightmap.png` from `path`.
|
||||
///
|
||||
/// `default_sea_level` is the fallback used when the PNG lacks a `sea_level`
|
||||
/// tEXt chunk. Delegates to [`run_cascade_from_heightmap`] for the layer work.
|
||||
pub fn run_cascade(
|
||||
body_seed: SeedChain,
|
||||
body_id: &str,
|
||||
heightmap_path: &Path,
|
||||
default_sea_level: f32,
|
||||
up_to: CascadeLayer,
|
||||
) -> Result<CascadeSnapshot, HeightmapLoadError> {
|
||||
// Layer 0 — the cascade's input; always loaded.
|
||||
let heightmap = heightmap::load_heightmap_png(heightmap_path, body_id, default_sea_level)?;
|
||||
Ok(run_cascade_from_heightmap(body_seed, heightmap, up_to))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::seed::SeedDomain;
|
||||
|
||||
/// A small synthetic heightmap with a diagonal slope so drainage and feature
|
||||
/// extraction have real structure to work on.
|
||||
fn test_heightmap() -> BodyHeightmap {
|
||||
let (width, height) = (64u32, 32u32);
|
||||
let n = (width * height) as usize;
|
||||
let data = (0..n)
|
||||
.map(|i| {
|
||||
let r = (i / width as usize) as f32 / height as f32;
|
||||
let c = (i % width as usize) as f32 / width as f32;
|
||||
(r * 0.6 + c * 0.4).min(1.0)
|
||||
})
|
||||
.collect();
|
||||
BodyHeightmap {
|
||||
body_id: "test_body".into(),
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
sea_level: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
fn body_seed() -> SeedChain {
|
||||
SeedChain::root(42).derive(SeedDomain::Body, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heightmap_layer_skips_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Heightmap);
|
||||
assert_eq!(snap.body_id, "test_body");
|
||||
assert!(
|
||||
snap.layer1.is_none(),
|
||||
"Layer 1 must not run when up_to = Heightmap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topography_layer_runs_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Topography);
|
||||
let l1 = snap.layer1.expect("Layer 1 should have run");
|
||||
assert_eq!(l1.body_id, "test_body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_is_deterministic() {
|
||||
let extract = |s: CascadeSnapshot| {
|
||||
let l1 = s.layer1.expect("layer1");
|
||||
l1.attractors
|
||||
.iter()
|
||||
.map(|a| (a.position, a.attractor_type, a.sub_biome))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let a = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
let b = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"same heightmap must yield identical Layer-1 attractors"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_are_ordered() {
|
||||
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cascade_missing_file_is_err() {
|
||||
// The file-loading path returns an error (not a panic) for a bad path.
|
||||
let res = run_cascade(
|
||||
body_seed(),
|
||||
"missing",
|
||||
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
|
||||
0.3,
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -172,13 +172,14 @@ pub struct DistrictMix {
|
||||
/// `total_districts` is the number of districts to allocate. A good default is
|
||||
/// `max(4, population_tier * 2)`.
|
||||
///
|
||||
/// `seed` is the city-level RNG seed (D-010 determinism).
|
||||
/// `chain` is this city's position in the deterministic seed tree; the district
|
||||
/// allocation draws from its `Layer4Quarter` sub-stream (D-224, D-010).
|
||||
pub fn compute_district_mix(
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
archetype: &PoliticalArchetype,
|
||||
total_districts: u32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictMix {
|
||||
let tier = population_tier(population);
|
||||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||||
@@ -197,7 +198,10 @@ pub fn compute_district_mix(
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(1));
|
||||
// id = 0: one mix per district. Sibling districts/quarters are separated by
|
||||
// the distinct `chain` each receives (caller-derived; #957 threads a
|
||||
// per-quarter chain), so a fixed id here does not collide across siblings.
|
||||
let mut lcg = chain.derive(SeedDomain::Layer4Quarter, 0).atlas_rng();
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
@@ -262,7 +266,7 @@ mod tests {
|
||||
"manufacturing",
|
||||
&PoliticalArchetype::Industrial,
|
||||
8,
|
||||
42,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
// total may exceed requested due to guarantees
|
||||
assert!(mix.total >= 8, "district count should be >= requested");
|
||||
@@ -277,7 +281,7 @@ mod tests {
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
6,
|
||||
7,
|
||||
SeedChain::root(7),
|
||||
);
|
||||
let transit = mix
|
||||
.districts
|
||||
@@ -312,14 +316,14 @@ mod tests {
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
SeedChain::root(99),
|
||||
);
|
||||
let mix2 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
SeedChain::root(99),
|
||||
);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
@@ -331,10 +335,15 @@ mod tests {
|
||||
"financial",
|
||||
&PoliticalArchetype::Corporate,
|
||||
8,
|
||||
42,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
let mix_pioneer = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
8,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
let mix_pioneer =
|
||||
compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(
|
||||
mix_corp.districts, mix_pioneer.districts,
|
||||
@@ -344,7 +353,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||||
let mix = compute_district_mix(
|
||||
2_000_000,
|
||||
"military",
|
||||
&PoliticalArchetype::Military,
|
||||
8,
|
||||
SeedChain::root(10),
|
||||
);
|
||||
let admin = mix
|
||||
.districts
|
||||
.iter()
|
||||
@@ -358,13 +373,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_district_types_can_appear() {
|
||||
// With enough districts and a balanced role, every type should appear at least once.
|
||||
// Reachability check: every type has a clamped weight of at least 1
|
||||
// (compute_district_mix `.max(1)`), so all are reachable. This asserts the
|
||||
// specific deterministic LCG sequence for SeedChain::root(0); 500 draws is
|
||||
// far beyond what's needed (the rarest type here, Administrative, has
|
||||
// weight ≈ 2.7%, so P(missed over 500 draws) ≈ 1e-6), so it stays green
|
||||
// across seeds — but it is the seed-0 sequence that's pinned, not a
|
||||
// probabilistic guarantee. Not a realistic city size; a unit test of the
|
||||
// allocator's reachability, not of a real settlement.
|
||||
let requested: u32 = 500;
|
||||
let mix = compute_district_mix(
|
||||
50_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
50,
|
||||
0,
|
||||
requested,
|
||||
SeedChain::root(0),
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix
|
||||
@@ -373,8 +396,7 @@ mod tests {
|
||||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(
|
||||
present,
|
||||
"DistrictType {:?} never appeared in 50-district mix",
|
||||
dt
|
||||
"DistrictType {dt:?} never appeared in {requested}-district mix"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ use crate::atlas::features::{self, TerrainAnalysis};
|
||||
use crate::atlas::heightmap::BodyHeightmap;
|
||||
use crate::atlas::subbiome;
|
||||
use crate::simulation::generator::{AttractorType, GeographicAttractor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Full Layer-1 result for one body.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Layer1Output {
|
||||
pub body_id: String,
|
||||
pub river_network: RiverNetwork,
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
pub mod attractor_matching;
|
||||
pub mod block_irregularity;
|
||||
pub mod body_world_state;
|
||||
pub mod cascade;
|
||||
pub mod district_mix;
|
||||
pub mod drainage;
|
||||
pub mod features;
|
||||
pub mod gen_queue;
|
||||
pub mod heightmap;
|
||||
pub mod layer1;
|
||||
pub mod rng;
|
||||
pub mod skeleton_gen;
|
||||
pub mod subbiome;
|
||||
pub mod tile_condition;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Seeded LCG for deterministic generation (D-010).
|
||||
//!
|
||||
//! Shared by all atlas generation modules that need seeded randomness.
|
||||
//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant.
|
||||
//!
|
||||
//! Callers are responsible for any seed pre-mixing before calling `AtlasRng::new`.
|
||||
|
||||
/// Seeded linear congruential generator (D-010).
|
||||
pub struct AtlasRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl AtlasRng {
|
||||
/// Create a new RNG from a pre-mixed seed.
|
||||
///
|
||||
/// Callers must ensure the seed is non-degenerate (avoid passing 0 directly
|
||||
/// if the seed could realistically be 0 — add a constant before calling).
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Next pseudorandom `u32` (top 31 bits of the LCG state).
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_sequence() {
|
||||
let mut a = AtlasRng::new(42);
|
||||
let mut b = AtlasRng::new(42);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u32(), b.next_u32());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_differ() {
|
||||
let mut a = AtlasRng::new(1);
|
||||
let mut b = AtlasRng::new(2);
|
||||
let vals_a: Vec<u32> = (0..10).map(|_| a.next_u32()).collect();
|
||||
let vals_b: Vec<u32> = (0..10).map(|_| b.next_u32()).collect();
|
||||
assert_ne!(vals_a, vals_b);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
@@ -39,14 +39,14 @@ use crate::simulation::generator::{
|
||||
/// - `economic_role`: Economic role string (one of the 10 canonical values).
|
||||
/// - `district_id`: Content-addressable identifier for this district.
|
||||
/// - `founding_age_years`: Years since founding — controls block irregularity.
|
||||
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
|
||||
/// - `chain`: This district's position in the deterministic seed tree (D-224 SeedChain).
|
||||
pub fn generate_skeleton(
|
||||
context: &CityGenerationContext,
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
district_id: DistrictId,
|
||||
founding_age_years: u32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictSkeleton {
|
||||
// ── 1. SettingType ────────────────────────────────────────────────────
|
||||
// Pass through the surrounding_biome from context — it already encodes
|
||||
@@ -59,7 +59,7 @@ pub fn generate_skeleton(
|
||||
|
||||
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
|
||||
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, chain);
|
||||
|
||||
// ── 4. District mix → block grid ─────────────────────────────────────
|
||||
// A single district occupies a 4×4 block grid = 16 blocks.
|
||||
@@ -69,11 +69,11 @@ pub fn generate_skeleton(
|
||||
economic_role,
|
||||
&context.political_archetype,
|
||||
total_blocks,
|
||||
seed,
|
||||
chain,
|
||||
);
|
||||
|
||||
// ── 5. Multi-block reservations ───────────────────────────────────────
|
||||
let reservations = derive_reservations(tier, economic_role, seed);
|
||||
let reservations = derive_reservations(tier, economic_role, chain);
|
||||
|
||||
// Build the reservation lookup: block position → reservation id.
|
||||
let mut block_reservation: [[Option<ReservationId>; 4]; 4] = [[None, None, None, None]; 4];
|
||||
@@ -105,7 +105,7 @@ pub fn generate_skeleton(
|
||||
|
||||
DistrictSkeleton {
|
||||
district_id,
|
||||
seed,
|
||||
seed: chain.seed(),
|
||||
district_type: district_type_from_mix(&primary_district_type),
|
||||
context: String::new(), // stub — DistrictContext = String
|
||||
world_tier: context.world_tier.clone(),
|
||||
@@ -223,7 +223,7 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C
|
||||
fn derive_layout_mode(
|
||||
archetype: &PoliticalArchetype,
|
||||
irregularity: f32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictLayoutMode {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission
|
||||
@@ -233,7 +233,7 @@ fn derive_layout_mode(
|
||||
|
||||
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
|
||||
// Organic: generate per-block offsets and rotations seeded from district seed.
|
||||
let placements = organic_placements(irregularity, seed);
|
||||
let placements = organic_placements(irregularity, chain);
|
||||
DistrictLayoutMode::Organic { placements }
|
||||
}
|
||||
}
|
||||
@@ -243,9 +243,11 @@ fn derive_layout_mode(
|
||||
///
|
||||
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(0x9e37_79b9_7f4a_7c15));
|
||||
// id = 0: one placement pass per district. Sibling separation comes from the
|
||||
// distinct `chain` per district/quarter (caller-derived; #957), not the id.
|
||||
let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng();
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
@@ -362,7 +364,7 @@ fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
fn derive_reservations(
|
||||
pop_tier: u8,
|
||||
economic_role: &str,
|
||||
_seed: u64,
|
||||
_chain: SeedChain,
|
||||
) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -429,14 +431,14 @@ mod tests {
|
||||
fn setting_station_passthrough() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.surrounding_biome = SettingType::Station;
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, SeedChain::root(42));
|
||||
assert!(matches!(sk.setting, SettingType::Station));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_urban_for_city_on_planet() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
assert!(matches!(sk.setting, SettingType::Urban));
|
||||
}
|
||||
|
||||
@@ -444,14 +446,14 @@ mod tests {
|
||||
fn complexity_epicenter_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 10M pop → pop_tier = 1 → Full on Epicenter
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_waypoint_tiny_pop_is_empty() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Empty);
|
||||
}
|
||||
|
||||
@@ -459,7 +461,7 @@ mod tests {
|
||||
fn complexity_backwater_low_pop_is_moderate() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Moderate);
|
||||
}
|
||||
|
||||
@@ -467,7 +469,7 @@ mod tests {
|
||||
fn complexity_backwater_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
@@ -475,28 +477,28 @@ mod tests {
|
||||
fn complexity_passage_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42);
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commission_is_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, SeedChain::root(99));
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_pioneer_is_organic() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, SeedChain::root(99));
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_grid_is_fully_populated() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
// All 16 blocks must have valid positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
@@ -511,7 +513,7 @@ mod tests {
|
||||
fn no_reservations_for_small_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// pop_tier 0, not transit_hub → no reservations.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert!(sk.reservations.is_empty());
|
||||
}
|
||||
|
||||
@@ -519,7 +521,7 @@ mod tests {
|
||||
fn park_reservation_for_large_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → pop_tier 2 → park reservation.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
|
||||
let has_park = sk
|
||||
.reservations
|
||||
.iter()
|
||||
@@ -531,7 +533,7 @@ mod tests {
|
||||
fn transit_terminal_for_transit_hub_role() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// pop_tier 0 but transit_hub → terminal reservation.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, SeedChain::root(42));
|
||||
let has_terminal = sk
|
||||
.reservations
|
||||
.iter()
|
||||
@@ -543,7 +545,7 @@ mod tests {
|
||||
fn reserved_blocks_linked_in_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
|
||||
// All park blocks must reference the park reservation (id=1).
|
||||
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
let b = &sk.blocks[row as usize][col as usize];
|
||||
@@ -557,8 +559,22 @@ mod tests {
|
||||
#[test]
|
||||
fn determinism_same_seed_same_output() {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk1 = generate_skeleton(
|
||||
&ctx,
|
||||
2_000_000,
|
||||
"manufacturing",
|
||||
77,
|
||||
250,
|
||||
SeedChain::root(12345),
|
||||
);
|
||||
let sk2 = generate_skeleton(
|
||||
&ctx,
|
||||
2_000_000,
|
||||
"manufacturing",
|
||||
77,
|
||||
250,
|
||||
SeedChain::root(12345),
|
||||
);
|
||||
// Compare block grid zoning and positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod seed;
|
||||
pub mod settings;
|
||||
pub mod simulation;
|
||||
pub mod storyteller;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Deterministic seed derivation and the generation RNG primitive (D-224).
|
||||
//!
|
||||
//! [`SeedChain`] is the single sanctioned path from the master world seed to any
|
||||
//! per-body / per-layer / per-district sub-stream. Derivation is domain-separated
|
||||
//! `splitmix64` mixing: deterministic, full-avalanche, integer-only (D-010 #4),
|
||||
//! and chainable (`root → Body → Layer3Settlement → Quarter → Block`).
|
||||
//!
|
||||
//! This module is the home of every deterministic-seeding primitive:
|
||||
//! - [`splitmix64`] — the one canonical mixer, shared with
|
||||
//! [`crate::simulation::rng`], where `EntityRng` first introduced it
|
||||
//! specifically to avoid the `wrapping_add` collision class
|
||||
//! (`(seed=0,id=N) == (seed=1,id=N-1)`).
|
||||
//! - [`AtlasRng`] — the integer-only LCG stream used by the generation cascade,
|
||||
//! normally produced via [`SeedChain::atlas_rng`].
|
||||
//! - [`SeedChain`] / [`SeedDomain`] — the derivation contract itself.
|
||||
|
||||
/// SplitMix64 finalizer — strong avalanche, applied to every seed input.
|
||||
///
|
||||
/// Steele, Lea & Flood (2014). Integer-only (D-010 #4). `splitmix64(0)` returns
|
||||
/// the documented reference value `0xe220a8397b1dcdaf`.
|
||||
pub(crate) fn splitmix64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e3779b97f4a7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// FNV-1a (64-bit) hash of a string — the repo's deterministic `&str → u64`
|
||||
/// convention (matches `TemplateId`/`TriangleId` in `simulation::triangle` and
|
||||
/// the `observer` snapshot hash). The single sanctioned way to turn a string
|
||||
/// `body_id` into a `SeedDomain::Body` id (D-224), so two callers can never
|
||||
/// derive different worlds from the same seed via different ad-hoc hashes.
|
||||
pub(crate) fn fnv1a_64(s: &str) -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
|
||||
for byte in s.bytes() {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x100_0000_01b3); // FNV-1a prime
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Seeded linear congruential generator (D-010).
|
||||
///
|
||||
/// Knuth's LCG parameters — integer-only arithmetic, no `f32`, D-010 compliant.
|
||||
/// The generation cascade's pseudorandom stream. Normally seeded through
|
||||
/// [`SeedChain::atlas_rng`]; `SeedChain` output is well-distributed, so no
|
||||
/// caller-side pre-mixing is needed.
|
||||
pub struct AtlasRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl AtlasRng {
|
||||
/// Create a new RNG from a seed.
|
||||
///
|
||||
/// A `SeedChain`-derived seed is already well-distributed. If seeding from a
|
||||
/// raw value that could realistically be 0, mix it first (or derive it
|
||||
/// through [`SeedChain`]).
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Next pseudorandom `u32` (top 31 bits of the LCG state).
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed-derivation domain (D-224).
|
||||
///
|
||||
/// Each variant is a distinct domain tag, so a per-body stream and a per-district
|
||||
/// stream derived with the same `id` never share a sequence. The discriminants
|
||||
/// are explicit and **stable**: changing one re-rolls every world that derives a
|
||||
/// seed through it, so they are append-only — never renumber an existing variant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u64)]
|
||||
pub enum SeedDomain {
|
||||
/// Per-body generation (keyed by body StableId).
|
||||
Body = 1,
|
||||
/// Layer 1 topography (reserved — currently RNG-free; see D-224 scope note).
|
||||
Layer1Topography = 2,
|
||||
/// Layer 3 settlement placement (keyed by candidate/site index).
|
||||
Layer3Settlement = 3,
|
||||
/// Layer 4 quarter skeleton (keyed by quarter index).
|
||||
Layer4Quarter = 4,
|
||||
/// Block-level detail (keyed by block index within a quarter).
|
||||
Block = 5,
|
||||
/// NPC generation (keyed by NPC StableId).
|
||||
Npc = 6,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
///
|
||||
/// `Copy` and cheap to pass by value. Construct with [`SeedChain::root`] from the
|
||||
/// master world seed, then descend with [`SeedChain::derive`]. Materialize an
|
||||
/// [`AtlasRng`] with [`SeedChain::atlas_rng`], or read the raw seed with
|
||||
/// [`SeedChain::seed`] to feed `SimRng::new` or to derive further.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SeedChain(u64);
|
||||
|
||||
impl SeedChain {
|
||||
/// Root of the chain — the master world seed.
|
||||
pub fn root(world_seed: u64) -> Self {
|
||||
Self(world_seed)
|
||||
}
|
||||
|
||||
/// This body's position in the seed tree, from the master `world_seed` and
|
||||
/// its string `body_id` (D-224). The one canonical `body_id → u64` path:
|
||||
/// `root(world_seed).derive(Body, fnv1a_64(body_id))`. Use this everywhere a
|
||||
/// body needs a seed, so all callers agree on the mapping.
|
||||
pub fn for_body(world_seed: u64, body_id: &str) -> Self {
|
||||
Self::root(world_seed).derive(SeedDomain::Body, fnv1a_64(body_id))
|
||||
}
|
||||
|
||||
/// Derive a child seed for `(domain, id)`.
|
||||
///
|
||||
/// `splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)` (D-224, load-bearing).
|
||||
/// Deterministic, domain-separated, full-avalanche, integer-only.
|
||||
pub fn derive(self, domain: SeedDomain, id: u64) -> Self {
|
||||
Self(splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id))
|
||||
}
|
||||
|
||||
/// Materialize the integer-only generation RNG at this point in the chain.
|
||||
pub fn atlas_rng(self) -> AtlasRng {
|
||||
AtlasRng::new(self.0)
|
||||
}
|
||||
|
||||
/// The raw derived seed — feed to `SimRng::new` or a further
|
||||
/// [`SeedChain::derive`].
|
||||
pub fn seed(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splitmix64_known_vector() {
|
||||
// Documented SplitMix64 output for seed 0.
|
||||
assert_eq!(splitmix64(0), 0xe220a8397b1dcdaf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitmix64_avalanche() {
|
||||
// Adjacent inputs produce uncorrelated outputs (the property that makes
|
||||
// it preferable to wrapping_add).
|
||||
let a = splitmix64(1);
|
||||
let b = splitmix64(2);
|
||||
assert_ne!(a, b);
|
||||
assert!((a ^ b).count_ones() > 16, "weak avalanche between 1 and 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_rng_deterministic_sequence() {
|
||||
let mut a = AtlasRng::new(42);
|
||||
let mut b = AtlasRng::new(42);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u32(), b.next_u32());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_rng_different_seeds_differ() {
|
||||
let mut a = AtlasRng::new(1);
|
||||
let mut b = AtlasRng::new(2);
|
||||
let vals_a: Vec<u32> = (0..10).map(|_| a.next_u32()).collect();
|
||||
let vals_b: Vec<u32> = (0..10).map(|_| b.next_u32()).collect();
|
||||
assert_ne!(vals_a, vals_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_rng_from_seedchain_is_deterministic() {
|
||||
let chain = SeedChain::root(42).derive(SeedDomain::Body, 7);
|
||||
let mut a = chain.atlas_rng();
|
||||
let mut b = chain.atlas_rng();
|
||||
assert_eq!(a.next_u32(), b.next_u32());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_is_deterministic() {
|
||||
let a = SeedChain::root(42).derive(SeedDomain::Body, 7);
|
||||
let b = SeedChain::root(42).derive(SeedDomain::Body, 7);
|
||||
assert_eq!(a.seed(), b.seed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domains_are_separated() {
|
||||
// Same root + same id, different domain → different stream.
|
||||
let root = SeedChain::root(42);
|
||||
let body = root.derive(SeedDomain::Body, 5).seed();
|
||||
let quarter = root.derive(SeedDomain::Layer4Quarter, 5).seed();
|
||||
let block = root.derive(SeedDomain::Block, 5).seed();
|
||||
assert_ne!(body, quarter);
|
||||
assert_ne!(body, block);
|
||||
assert_ne!(quarter, block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_separated() {
|
||||
let root = SeedChain::root(42);
|
||||
assert_ne!(
|
||||
root.derive(SeedDomain::Body, 1).seed(),
|
||||
root.derive(SeedDomain::Body, 2).seed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_world_seeds_diverge() {
|
||||
let a = SeedChain::root(1).derive(SeedDomain::Body, 7).seed();
|
||||
let b = SeedChain::root(2).derive(SeedDomain::Body, 7).seed();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_compose_deterministically_and_order_matters() {
|
||||
let base = SeedChain::root(42).derive(SeedDomain::Body, 7);
|
||||
// Deterministic at each depth.
|
||||
assert_eq!(
|
||||
base.derive(SeedDomain::Block, 3).seed(),
|
||||
SeedChain::root(42)
|
||||
.derive(SeedDomain::Body, 7)
|
||||
.derive(SeedDomain::Block, 3)
|
||||
.seed()
|
||||
);
|
||||
// A deeper chain differs from deriving the same leaf off the root.
|
||||
assert_ne!(
|
||||
base.derive(SeedDomain::Block, 3).seed(),
|
||||
SeedChain::root(42).derive(SeedDomain::Block, 3).seed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_domain_discriminants_are_pinned() {
|
||||
// Load-bearing (D-224): renumbering a variant re-rolls every world that
|
||||
// derives through it. This guard fails CI the moment a tag changes —
|
||||
// append new variants, never renumber existing ones.
|
||||
assert_eq!(SeedDomain::Body as u64, 1);
|
||||
assert_eq!(SeedDomain::Layer1Topography as u64, 2);
|
||||
assert_eq!(SeedDomain::Layer3Settlement as u64, 3);
|
||||
assert_eq!(SeedDomain::Layer4Quarter as u64, 4);
|
||||
assert_eq!(SeedDomain::Block as u64, 5);
|
||||
assert_eq!(SeedDomain::Npc as u64, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_zero_is_non_degenerate() {
|
||||
// World seed 0 must still produce a well-distributed stream.
|
||||
let derived = SeedChain::root(0).derive(SeedDomain::Body, 0);
|
||||
assert_ne!(derived.seed(), 0, "derived seed must not be zero");
|
||||
let mut rng = derived.atlas_rng();
|
||||
assert_ne!(rng.next_u32(), 0, "RNG from world seed 0 must not stall");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_body_deterministic_and_distinct() {
|
||||
assert_eq!(
|
||||
SeedChain::for_body(42, "GJ1c").seed(),
|
||||
SeedChain::for_body(42, "GJ1c").seed()
|
||||
);
|
||||
assert_ne!(
|
||||
SeedChain::for_body(42, "GJ1c").seed(),
|
||||
SeedChain::for_body(42, "GJ1d").seed()
|
||||
);
|
||||
// for_body is exactly root().derive(Body, fnv1a_64(id)).
|
||||
assert_eq!(
|
||||
SeedChain::for_body(42, "GJ1c").seed(),
|
||||
SeedChain::root(42)
|
||||
.derive(SeedDomain::Body, fnv1a_64("GJ1c"))
|
||||
.seed()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -371,22 +371,27 @@ pub enum TerritorialStatus {
|
||||
|
||||
/// The type of terrain feature that attracts settlement placement.
|
||||
/// Source: D-195, D-209
|
||||
// `#[repr(u8)]` with explicit discriminants: `AttractorType` is cast `as u8` as
|
||||
// the primary sort key for the attractor list (`features.rs`), so the layout is
|
||||
// load-bearing — reordering would change attractor ordering and the cascade
|
||||
// golden. Append new variants; never renumber or reorder existing ones.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum AttractorType {
|
||||
/// Where a river meets sea level or coastline. Historically high-value.
|
||||
RiverMouth,
|
||||
RiverMouth = 0,
|
||||
/// Proximity to coast without a river mouth. Port access.
|
||||
CoastalAccess,
|
||||
CoastalAccess = 1,
|
||||
/// Where a river crosses a topographic saddle or confluence point.
|
||||
RiverCrossing,
|
||||
RiverCrossing = 2,
|
||||
/// Local elevation minimum; flat, arable, sheltered.
|
||||
ValleyFloor,
|
||||
ValleyFloor = 3,
|
||||
/// Saddle point between adjacent drainage basins; controls a mountain pass.
|
||||
PassEntrance,
|
||||
PassEntrance = 4,
|
||||
/// Adjacent to a lake polygon.
|
||||
LakeShore,
|
||||
LakeShore = 5,
|
||||
/// Flat terrain away from all other attractors; fallback for plains settlements.
|
||||
PlainCenter,
|
||||
PlainCenter = 6,
|
||||
}
|
||||
|
||||
/// Fine-grained terrain classification carried by each `GeographicAttractor`.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Injectable ChaCha RNG resource for deterministic replay (D-030)
|
||||
// Ensures same seed produces same outcomes
|
||||
|
||||
use crate::seed::splitmix64;
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
@@ -61,16 +62,6 @@ impl EntityRng {
|
||||
}
|
||||
}
|
||||
|
||||
/// Splitmix64 mixing function — good avalanche properties for seed derivation.
|
||||
/// Ensures that similar inputs (e.g., consecutive StableIds) produce
|
||||
/// uncorrelated output seeds.
|
||||
fn splitmix64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e3779b97f4a7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Golden-seed regression for the Layer 0→1 generation cascade (#952, D-200, D-224).
|
||||
//!
|
||||
//! Pins two artifacts for one fixed input heightmap, in a single diffable JSON
|
||||
//! golden (matching the `golden_suite.rs` convention):
|
||||
//! - **Layer 0** — SHA-256 of the source `heightmap.png` bytes. Flips if the
|
||||
//! Python heightmap generator (or the committed file) changes.
|
||||
//! - **Layer 1** — the serialized `Layer1Output` of the cascade run on a
|
||||
//! downsampled copy. Flips if the Rust drainage / feature / sub-biome code
|
||||
//! changes.
|
||||
//!
|
||||
//! The input is a real committed body heightmap (the #963 bake output), so this
|
||||
//! is an end-to-end determinism guard. The cascade is downsampled to keep the
|
||||
//! golden small while still exercising the full Layer-1 pipeline.
|
||||
//!
|
||||
//! Regenerate after an intended change:
|
||||
//! UPDATE_GOLDEN=1 cargo test --test cascade_golden
|
||||
//!
|
||||
//! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a
|
||||
//! different architecture could in principle round differently — regenerate
|
||||
//! per-arch if CI ever moves off x86_64.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
||||
use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
||||
use settled_reach_server::seed::SeedChain;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Source heightmap — a real committed body, relative to the server manifest dir.
|
||||
const SOURCE_HEIGHTMAP: &str = "../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
|
||||
/// Downsample target: large enough that GJ1c's drainage produces a real river
|
||||
/// network (not just attractors), small enough for a compact golden.
|
||||
const DOWNSAMPLE: (u32, u32) = (256, 128);
|
||||
/// World seed for the run. Cosmetic here — Layers 0–1 are RNG-free, so changing
|
||||
/// it does not change the golden; it is carried only to exercise the SeedChain
|
||||
/// contract end-to-end (D-224). Seed-sensitivity gets pinned once Layer 3+ lands.
|
||||
const WORLD_SEED: u64 = 42;
|
||||
const GOLDEN: &str = "tests/golden/cascade_layer1.json";
|
||||
|
||||
#[test]
|
||||
fn cascade_layer0_to_1_matches_golden() {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let src = manifest.join(SOURCE_HEIGHTMAP);
|
||||
|
||||
// ── Layer 0 — load + hash the source heightmap.png. ─────────────────────
|
||||
let png_bytes = std::fs::read(&src)
|
||||
.unwrap_or_else(|e| panic!("read source heightmap {}: {e}", src.display()));
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&png_bytes);
|
||||
let sha = format!("{:x}", hasher.finalize());
|
||||
|
||||
let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode source heightmap");
|
||||
|
||||
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
|
||||
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
|
||||
let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c");
|
||||
let snapshot = run_cascade_from_heightmap(body_seed, small, CascadeLayer::Topography);
|
||||
let layer1 = snapshot.layer1.expect("Layer 1 ran");
|
||||
|
||||
let actual = json!({
|
||||
"source_heightmap": SOURCE_HEIGHTMAP,
|
||||
"source_sha256": sha,
|
||||
"downsample": [DOWNSAMPLE.0, DOWNSAMPLE.1],
|
||||
"layer1": serde_json::to_value(&layer1).expect("serialize Layer1Output"),
|
||||
});
|
||||
let actual_json = serde_json::to_string_pretty(&actual).expect("format JSON") + "\n";
|
||||
|
||||
let golden_path = manifest.join(GOLDEN);
|
||||
|
||||
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
||||
std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden");
|
||||
std::fs::write(&golden_path, &actual_json).expect("write golden");
|
||||
eprintln!(
|
||||
"Golden written: {} ({} bytes)",
|
||||
golden_path.display(),
|
||||
actual_json.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Golden not found: {}. Run: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n{e}",
|
||||
golden_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
// Compare as parsed JSON so whitespace/formatting never causes spurious diffs.
|
||||
let actual_v: Value = serde_json::from_str(&actual_json).expect("reparse actual JSON");
|
||||
let golden_v: Value = serde_json::from_str(&golden_json).expect("parse golden JSON");
|
||||
|
||||
if actual_v != golden_v {
|
||||
let count =
|
||||
|v: &Value, ptr: &str| v.pointer(ptr).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
panic!(
|
||||
"Cascade golden mismatch.\n \
|
||||
source_sha256: golden={} actual={}\n \
|
||||
river_cells: golden={} actual={}\n \
|
||||
attractors: golden={} actual={}\n\
|
||||
To update: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n Golden: {}",
|
||||
golden_v["source_sha256"],
|
||||
actual_v["source_sha256"],
|
||||
count(&golden_v, "/layer1/river_network/river_cells"),
|
||||
count(&actual_v, "/layer1/river_network/river_cells"),
|
||||
count(&golden_v, "/layer1/attractors"),
|
||||
count(&actual_v, "/layer1/attractors"),
|
||||
golden_path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user