Merge remote-tracking branch 'origin/tile-derivation-carriers'
This commit is contained in:
@@ -1770,6 +1770,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Rationale:** [D-227](#d-227)/[D-228](#d-228) fixed *what* a tile is (derive-don't-store voxels; orthogonal derived axes); D-239 fixes *how* the finished upper cascade (L0–L4) becomes actual tiles — the one unbuilt layer. The district-temperature primitive collapses the scattered climate inputs (precipitation, glaciation, season, snow/ice, vegetation) onto one derived scalar + moisture, keeping the whole climate branch deterministic and null-cutting airless bodies cleanly. Prevent-at-source seams + the warp give coherent, organic terrain without a runtime patch. The frozen vocabulary protects authored Atlas/wiki content while letting the classifier evolve.
|
||||
- **Raised by:** tile-derivation-contract workshop (Tyre — refinement chain, warp, determinism; Gestalt — 8 families, game-feel; Troblum — feasibility, warp precision, scale corrections; Miri — believability laws, vocabulary, lore reconciliation), lead-interviewed decisions + an adversarial verification pass, 2026-06-07.
|
||||
- **Dissent:** Tyre's initial cross-family elevation-blend was resolved against (prevent-at-source). Early-integer-truncation of the warp (raised against Gestalt's `ElevationDelta` ranges and by Tyre) was resolved against in favour of f64-to-voxel.
|
||||
- **Implementation note (T-1024, 2026-06-07):** §1 per-body `RIVER_THRESHOLD` is now a derived field (`derive_river_threshold`) on `RegionProfile`. §2 district temperature is a nullable `f32` on `RegionProfile` sampled from a per-region climate derivation (`derive_temperature_c`); moisture is a separate integer primitive (`derive_moisture_q`, 0–100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded).
|
||||
- **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Climate constants for district temperature derivation (T-1024, D-239 §2).
|
||||
#
|
||||
# Source-canonical — loaded by the Rust simulation at runtime.
|
||||
# These values mirror the tuned constants from tooling/planet-gen/planet_simulation.py.
|
||||
# Changing these constants does NOT require a DB migration (runtime file, not DB).
|
||||
# To tune: edit here, rerun the server, inspect Atlas temperature maps.
|
||||
#
|
||||
# Two tables are required:
|
||||
# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class.
|
||||
# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class.
|
||||
#
|
||||
# Temperature derivation formula (D-239 §2):
|
||||
# T_base = T_stellar_equilibrium + greenhouse_offset_c[atmosphere]
|
||||
# T_mean = T_base + latitude_term + elevation_lapse_term
|
||||
# T_diurnal_amplitude = diurnal_amplitude_c[atmosphere]
|
||||
#
|
||||
# No atmosphere → temperature_c = None (airless body; D-227).
|
||||
|
||||
[greenhouse_offset_c]
|
||||
# Greenhouse warming contribution per atmosphere class (°C above bare rock).
|
||||
# "none" is not present — airless bodies skip the climate branch entirely (D-227).
|
||||
# Values from planet_simulation.py STAR_LUMINOSITY + greenhouse table.
|
||||
thin = 8
|
||||
standard = 33
|
||||
toxic = 33 # treated as standard greenhouse for thermal purposes
|
||||
breathable = 33 # synonym for standard
|
||||
dense = 80
|
||||
|
||||
[diurnal_amplitude_c]
|
||||
# Day/night swing amplitude (°C). The actual swing is ±amplitude around the
|
||||
# mean temperature; the morning minimum is T_mean - amplitude, noon maximum is
|
||||
# T_mean + amplitude. Thick/dense atmosphere = small swing (heat redistribution);
|
||||
# thin/no atmosphere = large swing.
|
||||
#
|
||||
# "none" is excluded — airless bodies have no climate temperature (D-227).
|
||||
# Values are plausible physics; tune without recompile by editing this file.
|
||||
thin = 50 # ~50°C day/night swing: large, like Mars
|
||||
standard = 15 # ~15°C: Earth-like moderate swing
|
||||
toxic = 20 # intermediate: thick but maybe less redistribution
|
||||
breathable = 15 # synonym for standard
|
||||
dense = 3 # ~3°C: Venus-like near-uniform temperature
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stellar luminosity lookup (relative to Sol = 1.0).
|
||||
# Midpoint per spectral type, matching planet_simulation.py STAR_LUMINOSITY.
|
||||
# Used to derive distance_au at runtime from orbital_period_days via Kepler's 3rd law.
|
||||
#
|
||||
# These values are also used to compute T_stellar_equilibrium:
|
||||
# T_eq_K = 278.5 * (luminosity ^ 0.25) / sqrt(distance_au)
|
||||
# ---------------------------------------------------------------------------
|
||||
[star_luminosity]
|
||||
O = 100000.0
|
||||
B = 1000.0
|
||||
A = 10.0
|
||||
F = 2.5
|
||||
G = 1.0
|
||||
K = 0.4
|
||||
M = 0.04
|
||||
@@ -183,6 +183,12 @@ CREATE TABLE IF NOT EXISTS bodies (
|
||||
-- derivation from planet_class is applied at query time by the generator.
|
||||
body_radius_km REAL,
|
||||
|
||||
-- Axial tilt in degrees (T-1024, D-239 §2). Sourced from planet-gen body-def
|
||||
-- frontmatter (wiki/star-systems/*/bodies/*/index.md, orbit.axial_tilt_deg).
|
||||
-- NULL until populate_axial_tilt_deg runs. Used by district temperature
|
||||
-- derivation: tilt determines latitude-band insolation gradient.
|
||||
axial_tilt_deg REAL,
|
||||
|
||||
-- Rendering
|
||||
-- terrain_reference: repo-root-relative path to the body's heightmap PNG.
|
||||
-- Convention (enforced by populate_terrain_reference.py and assumed by
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ use bevy_ecs::prelude::Resource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::region_profile::{RegionPos, RegionProfile};
|
||||
use crate::simulation::generator::{
|
||||
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
|
||||
};
|
||||
@@ -88,6 +89,12 @@ pub struct BodyWorldState {
|
||||
/// Populated by `GenCompletion::SkeletonGenerated` after the plan phase
|
||||
/// completes for each city. `BTreeMap` for D-010 determinism.
|
||||
pub quarters: BTreeMap<QuarterId, QuarterWorldState>,
|
||||
/// Per-region (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1).
|
||||
///
|
||||
/// Populated by the background cascade after Layer 1 completes.
|
||||
/// `BTreeMap` keyed by `RegionPos` for D-010 determinism.
|
||||
/// Empty until the RegionProfile layer has run.
|
||||
pub regions: BTreeMap<RegionPos, RegionProfile>,
|
||||
/// Last sim tick this entry was read. Used for LRU eviction.
|
||||
pub last_accessed: SimTick,
|
||||
}
|
||||
@@ -210,6 +217,7 @@ mod tests {
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
quarters: BTreeMap::new(),
|
||||
regions: BTreeMap::new(),
|
||||
last_accessed: tick,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,10 @@ use crate::atlas::attractor_matching::{
|
||||
match_cities, territorial_status_from_faction, CityPlacement, CityRecord,
|
||||
};
|
||||
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
||||
|
||||
@@ -43,6 +45,10 @@ pub enum CascadeLayer {
|
||||
/// `match_cities`. The carried `SeedChain` is unused here; later stochastic
|
||||
/// layers (Layer 4+) will consume it.
|
||||
Settlement,
|
||||
/// Layer — RegionProfile (~1 km carriers, D-239 §1, T-1023). Pure function of
|
||||
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
|
||||
/// declaration order (= Ord) is preserved — never reorder (D-010).
|
||||
RegionProfile,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
@@ -61,6 +67,16 @@ pub struct CascadeSnapshot {
|
||||
pub layer1: Option<Layer1Output>,
|
||||
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
|
||||
pub layer3: Option<Layer3Output>,
|
||||
/// RegionProfile layer — ~1 km carriers. `Some` once
|
||||
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
|
||||
pub layer_region: Option<LayerRegionOutput>,
|
||||
}
|
||||
|
||||
/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain
|
||||
/// profiles covering the whole body. Stored in `BodyWorldState.regions`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LayerRegionOutput {
|
||||
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
|
||||
}
|
||||
|
||||
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
|
||||
@@ -81,6 +97,7 @@ impl CascadeSnapshot {
|
||||
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
|
||||
};
|
||||
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
|
||||
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
@@ -91,6 +108,7 @@ impl CascadeSnapshot {
|
||||
attractors,
|
||||
placements,
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
regions,
|
||||
last_accessed: 0,
|
||||
}
|
||||
}
|
||||
@@ -138,11 +156,14 @@ fn run_layer3(
|
||||
/// `dominant_faction` is the body's authored system faction (D-237); it drives
|
||||
/// the `TerritorialStatus` on each province and the per-settlement spatial
|
||||
/// character (#956). `None` → `FrontierUnclaimed`.
|
||||
/// `body_params` supplies the physical parameters needed for the RegionProfile
|
||||
/// layer (T-1023); `None` → region layer skips (empty `regions` map).
|
||||
pub fn run_cascade_from_heightmap(
|
||||
body_seed: SeedChain,
|
||||
heightmap: BodyHeightmap,
|
||||
cities: &[CityRecord],
|
||||
dominant_faction: Option<&str>,
|
||||
body_params: Option<&BodyParams>,
|
||||
up_to: CascadeLayer,
|
||||
) -> CascadeSnapshot {
|
||||
let mut snapshot = CascadeSnapshot {
|
||||
@@ -151,6 +172,7 @@ pub fn run_cascade_from_heightmap(
|
||||
heightmap,
|
||||
layer1: None,
|
||||
layer3: None,
|
||||
layer_region: None,
|
||||
};
|
||||
|
||||
// TerritorialStatus is derived once per body from the system's dominant
|
||||
@@ -188,6 +210,36 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.layer3 = Some(l3);
|
||||
}
|
||||
|
||||
// RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params +
|
||||
// terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass.
|
||||
// Layer 1 already ran drainage inside run_layer1, but neither the drainage
|
||||
// result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both
|
||||
// here. Pure → determinism preserved, but the drainage re-run is NOT free at
|
||||
// the ~6 000-regions/body working scale (D-203).
|
||||
// PERF/TODO(T-1028/T-1032): cache TerrainAnalysis on Layer1Output to drop this
|
||||
// redundant drainage pass, and validate the combined cost against the D-239 §10
|
||||
// ~45 ms/body budget in the T-1031 verification harness. Latent for now — this
|
||||
// path only runs when body_params is Some, which production defers to T-1032.
|
||||
// If body_params is None, the region layer is skipped (e.g. unit tests without DB).
|
||||
if up_to >= CascadeLayer::RegionProfile {
|
||||
if let Some(params) = body_params {
|
||||
use crate::atlas::drainage;
|
||||
let dr = drainage::analyze(
|
||||
&snapshot.heightmap.data,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
snapshot.heightmap.sea_level,
|
||||
);
|
||||
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
|
||||
// ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions;
|
||||
// at full working resolution the budget is ~6 000/body (D-203).
|
||||
const CELLS_PER_REGION: usize = 8;
|
||||
let regions =
|
||||
region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION);
|
||||
snapshot.layer_region = Some(LayerRegionOutput { regions });
|
||||
}
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
@@ -202,6 +254,7 @@ pub fn run_cascade(
|
||||
default_sea_level: f32,
|
||||
cities: &[CityRecord],
|
||||
dominant_faction: Option<&str>,
|
||||
body_params: Option<&BodyParams>,
|
||||
up_to: CascadeLayer,
|
||||
) -> Result<CascadeSnapshot, HeightmapLoadError> {
|
||||
// Layer 0 — the cascade's input; always loaded.
|
||||
@@ -211,6 +264,7 @@ pub fn run_cascade(
|
||||
heightmap,
|
||||
cities,
|
||||
dominant_faction,
|
||||
body_params,
|
||||
up_to,
|
||||
))
|
||||
}
|
||||
@@ -252,6 +306,7 @@ mod tests {
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert_eq!(snap.body_id, "test_body");
|
||||
@@ -268,6 +323,7 @@ mod tests {
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
CascadeLayer::Topography,
|
||||
);
|
||||
let l1 = snap.layer1.expect("Layer 1 should have run");
|
||||
@@ -288,6 +344,7 @@ mod tests {
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
let b = extract(run_cascade_from_heightmap(
|
||||
@@ -295,6 +352,7 @@ mod tests {
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -311,6 +369,7 @@ mod tests {
|
||||
// breaking which layers run.
|
||||
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
|
||||
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
|
||||
assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,11 +382,52 @@ mod tests {
|
||||
0.3,
|
||||
&[],
|
||||
None,
|
||||
None, // body_params
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||
}
|
||||
|
||||
/// RegionProfile layer runs, produces regions, and is deterministic (T-1023).
|
||||
#[test]
|
||||
fn region_profile_layer_runs_and_is_deterministic() {
|
||||
use crate::atlas::region_profile::BodyParams;
|
||||
|
||||
let params = BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let run = || {
|
||||
run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
Some(¶ms),
|
||||
CascadeLayer::RegionProfile,
|
||||
)
|
||||
};
|
||||
let snap1 = run();
|
||||
let snap2 = run();
|
||||
let lr1 = snap1.layer_region.expect("layer_region should be Some");
|
||||
let lr2 = snap2.layer_region.expect("layer_region should be Some");
|
||||
assert!(!lr1.regions.is_empty(), "regions map must not be empty");
|
||||
assert_eq!(
|
||||
lr1.regions.len(),
|
||||
lr2.regions.len(),
|
||||
"region count deterministic"
|
||||
);
|
||||
// BTreeMap iteration order is deterministic — compare all entries.
|
||||
for (pos, p1) in &lr1.regions {
|
||||
let p2 = lr2.regions.get(pos).expect("matching pos in second run");
|
||||
assert_eq!(p1.river_threshold, p2.river_threshold);
|
||||
assert_eq!(p1.tectonic_class, p2.tectonic_class);
|
||||
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer 3 — settlement placement runs, places the body's settlements onto
|
||||
/// attractors, and is deterministic (#955, D-211).
|
||||
#[test]
|
||||
@@ -357,6 +457,7 @@ mod tests {
|
||||
test_heightmap(),
|
||||
&cities,
|
||||
Some("concord_assembly"),
|
||||
None, // body_params
|
||||
CascadeLayer::Settlement,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Anti-squaring domain warp (D-239 §4, T-1026).
|
||||
//!
|
||||
//! A stateless, hash-based pure function of `(seed, body_id, pos)` that returns
|
||||
//! a `(dx, dy)` displacement in metres, bounded to ±8 m. Used by downstream
|
||||
//! voxel derivation to suppress grid/seam artefacts without authoring.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! The warp is a **positional displacement in f64**, applied to the integer tile
|
||||
//! position and then truncated back to an integer voxel address with `as i32` (a
|
||||
//! cast, not a comparison). The ±8 m bound means the FMA-contraction ULP variance
|
||||
//! (~1e-15 m) is nine orders of magnitude below the 1 m voxel — no platform guard
|
||||
//! is needed (D-239 §4).
|
||||
//!
|
||||
//! ## D-010 compliance
|
||||
//!
|
||||
//! The warp is *positional math*, not a structural decision. All morphology /
|
||||
//! material / gating decisions downstream receive the final **integer** voxel
|
||||
//! address; the f64 path is fully contained in this module.
|
||||
//!
|
||||
//! ## SeedDomain extension
|
||||
//!
|
||||
//! Appends `DomainWarp = 7` to [`crate::seed::SeedDomain`] (discriminant-pinned;
|
||||
//! the test in `seed.rs` enforces append-only — never renumber existing variants).
|
||||
|
||||
use crate::seed::{splitmix64, SeedChain, SeedDomain};
|
||||
|
||||
/// ±8 m bound — the warp displacement is clamped to this range.
|
||||
const WARP_BOUND: f64 = 8.0;
|
||||
|
||||
/// Compute the anti-squaring domain warp for `pos` on `body_id`.
|
||||
///
|
||||
/// Returns `(dx, dy)` displacement in metres, each bounded to `[-8.0, 8.0]`.
|
||||
/// Pure and stateless — no lookup table, no thread-local state.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// let (dx, dy) = domain_warp(world_seed, "GJ1c", (tile_x, tile_y));
|
||||
/// let voxel_x = (tile_x as f64 + dx) as i32;
|
||||
/// let voxel_y = (tile_y as f64 + dy) as i32;
|
||||
/// ```
|
||||
///
|
||||
/// The final `as i32` is a **cast** (truncation toward zero), not a comparison —
|
||||
/// IEEE-754 deterministic across targets (D-239 §4).
|
||||
pub(crate) fn domain_warp(seed: u64, body_id: &str, pos: (i32, i32)) -> (f64, f64) {
|
||||
// Derive a per-body sub-seed using the canonical SeedChain path (D-224).
|
||||
let body_seed = SeedChain::for_body(seed, body_id);
|
||||
|
||||
// Derive two independent streams: one for dx, one for dy.
|
||||
// DomainWarp = 7 (appended to SeedDomain, never renumber).
|
||||
// Use pos-derived id so each cell in the same body gets a unique stream.
|
||||
let pos_id = pos_to_id(pos);
|
||||
|
||||
let seed_x = body_seed.derive(SeedDomain::DomainWarp, pos_id).seed();
|
||||
// Second stream: mix pos_id with a prime to get an independent y channel.
|
||||
let seed_y = body_seed
|
||||
.derive(
|
||||
SeedDomain::DomainWarp,
|
||||
splitmix64(pos_id ^ 0xdeadbeef_cafebabe),
|
||||
)
|
||||
.seed();
|
||||
|
||||
let dx = u64_to_displacement(seed_x);
|
||||
let dy = u64_to_displacement(seed_y);
|
||||
|
||||
(dx, dy)
|
||||
}
|
||||
|
||||
/// Fold `(x, y)` tile coordinates into a single u64 id for seed derivation.
|
||||
///
|
||||
/// Uses a bijective Cantor-pairing-style interleave with zigzag encoding so
|
||||
/// negative coordinates map to distinct non-negative ids. Integer-only (D-010).
|
||||
#[inline]
|
||||
fn pos_to_id(pos: (i32, i32)) -> u64 {
|
||||
// Zigzag-encode each axis: 0→0, -1→1, 1→2, -2→3, 2→4, …
|
||||
let zz = |v: i32| -> u64 {
|
||||
let v = v as i64;
|
||||
((v << 1) ^ (v >> 63)) as u64
|
||||
};
|
||||
let x = zz(pos.0);
|
||||
let y = zz(pos.1);
|
||||
// Cantor pairing: (x + y)*(x + y + 1)/2 + y — bijective N²→N.
|
||||
// We use 64-bit wrapping arithmetic; for the world sizes in play (< 2³¹ tiles)
|
||||
// this is collision-free in practice.
|
||||
let s = x.wrapping_add(y);
|
||||
s.wrapping_mul(s.wrapping_add(1))
|
||||
.wrapping_div(2)
|
||||
.wrapping_add(y)
|
||||
}
|
||||
|
||||
/// Map a raw u64 seed value to a displacement in `[-WARP_BOUND, +WARP_BOUND]`.
|
||||
///
|
||||
/// Passes the seed through `splitmix64` for avalanche, then maps the top 53
|
||||
/// bits to `[0.0, 1.0)` via the standard u64→f64 trick (`bits >> 11` gives a
|
||||
/// 53-bit mantissa), then shifts to `[-0.5, +0.5)` and scales by
|
||||
/// `2 * WARP_BOUND`.
|
||||
#[inline]
|
||||
fn u64_to_displacement(seed: u64) -> f64 {
|
||||
// One more mix for full avalanche from whatever derive() left.
|
||||
let mixed = splitmix64(seed);
|
||||
// Top 53 bits → [0.0, 1.0) using the integer-bit casting approach (not a
|
||||
// comparison, just a mantissa construction — IEEE-754 portable).
|
||||
let unit = (mixed >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
|
||||
// Shift to [-0.5, 0.5) then scale to [-WARP_BOUND, +WARP_BOUND).
|
||||
(unit - 0.5) * (2.0 * WARP_BOUND)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::seed::fnv1a_64;
|
||||
use std::thread;
|
||||
|
||||
/// Golden-vector regression anchor: a fixed (seed, body_id, pos) maps to
|
||||
/// fixed (dx, dy). Mirrors `splitmix64_known_vector` in seed.rs. Single-
|
||||
/// platform pins suffice — the ±8 m bound + downstream `as i32` truncation
|
||||
/// (D-239 §4) make cross-platform ULP drift irrelevant to the voxel address.
|
||||
///
|
||||
/// Recompute and re-pin these literals ONLY after a deliberate algorithm
|
||||
/// change (`pos_to_id`, `u64_to_displacement`, or the SeedDomain chain) —
|
||||
/// such a change re-rolls the warp for every body and needs a D-record
|
||||
/// amendment. If this test fails unexpectedly, the warp output drifted.
|
||||
#[test]
|
||||
fn golden_vector() {
|
||||
let (dx, dy) = domain_warp(42, "GJ1c", (100, -50));
|
||||
// Pinned literals — the canonical regression anchor for the warp output.
|
||||
assert_eq!(
|
||||
dx, -1.8472385880244921,
|
||||
"domain_warp dx drifted — algorithm changed?"
|
||||
);
|
||||
assert_eq!(
|
||||
dy, 5.033644613199796,
|
||||
"domain_warp dy drifted — algorithm changed?"
|
||||
);
|
||||
// Within the ±8 m bound, and the two channels are independent.
|
||||
assert!(dx.abs() <= WARP_BOUND && dy.abs() <= WARP_BOUND);
|
||||
assert_ne!(dx, dy);
|
||||
}
|
||||
|
||||
/// Truncation-as-cast (not rounding): the downstream consumer uses `as i32`.
|
||||
#[test]
|
||||
fn truncation_cast_not_rounding() {
|
||||
// Positive displacement truncates toward zero.
|
||||
let v: f64 = 3.9;
|
||||
assert_eq!(v as i32, 3, "positive truncates toward zero");
|
||||
// Negative displacement truncates toward zero (not floor).
|
||||
let v: f64 = -3.9;
|
||||
assert_eq!(v as i32, -3, "negative truncates toward zero");
|
||||
// Applying domain_warp to a tile and casting to voxel address.
|
||||
let (dx, dy) = domain_warp(1, "test_body", (10, 20));
|
||||
let vx = (10_f64 + dx) as i32;
|
||||
let vy = (20_f64 + dy) as i32;
|
||||
// The cast should produce a valid address (within ±WARP_BOUND of the tile).
|
||||
assert!((vx - 10).abs() <= WARP_BOUND as i32 + 1);
|
||||
assert!((vy - 20).abs() <= WARP_BOUND as i32 + 1);
|
||||
}
|
||||
|
||||
/// Two threads with the same inputs produce identical results (order-independence).
|
||||
#[test]
|
||||
fn two_threads_same_inputs_identical() {
|
||||
let handle_a = thread::spawn(|| domain_warp(99, "Kallast", (42, -17)));
|
||||
let handle_b = thread::spawn(|| domain_warp(99, "Kallast", (42, -17)));
|
||||
let (dxa, dya) = handle_a.join().unwrap();
|
||||
let (dxb, dyb) = handle_b.join().unwrap();
|
||||
assert_eq!(dxa, dxb, "dx must be identical across threads");
|
||||
assert_eq!(dya, dyb, "dy must be identical across threads");
|
||||
}
|
||||
|
||||
/// The warp varies with position (not a constant displacement).
|
||||
#[test]
|
||||
fn varies_with_position() {
|
||||
let a = domain_warp(1, "body", (0, 0));
|
||||
let b = domain_warp(1, "body", (1, 0));
|
||||
let c = domain_warp(1, "body", (0, 1));
|
||||
// Adjacent positions should produce different displacements.
|
||||
assert!(a != b || a != c, "warp must vary with position");
|
||||
}
|
||||
|
||||
/// The warp varies with body_id (different bodies get different warps).
|
||||
#[test]
|
||||
fn varies_with_body_id() {
|
||||
let a = domain_warp(1, "BodyA", (50, 50));
|
||||
let b = domain_warp(1, "BodyB", (50, 50));
|
||||
assert_ne!(a, b, "warp must vary with body_id");
|
||||
}
|
||||
|
||||
/// The warp varies with seed (different world seeds produce different warps).
|
||||
#[test]
|
||||
fn varies_with_seed() {
|
||||
let a = domain_warp(1, "BodyA", (50, 50));
|
||||
let b = domain_warp(2, "BodyA", (50, 50));
|
||||
assert_ne!(a, b, "warp must vary with seed");
|
||||
}
|
||||
|
||||
/// Displacement is always within ±8 m bound.
|
||||
#[test]
|
||||
fn bounds_respected() {
|
||||
for seed in [0u64, 1, 42, u64::MAX] {
|
||||
for body in ["GJ1c", "test", "Velen", "Kallast"] {
|
||||
for x in [-1000i32, -1, 0, 1, 1000] {
|
||||
for y in [-1000i32, -1, 0, 1, 1000] {
|
||||
let (dx, dy) = domain_warp(seed, body, (x, y));
|
||||
assert!(
|
||||
dx.abs() <= WARP_BOUND,
|
||||
"dx={dx} out of ±{WARP_BOUND} for ({x},{y})"
|
||||
);
|
||||
assert!(
|
||||
dy.abs() <= WARP_BOUND,
|
||||
"dy={dy} out of ±{WARP_BOUND} for ({x},{y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fnv1a_64` is the canonical body_id hasher used in SeedChain::for_body.
|
||||
/// Verify it's available here (compile-time sanity check).
|
||||
#[test]
|
||||
fn fnv1a_is_accessible() {
|
||||
let h = fnv1a_64("GJ1c");
|
||||
assert_ne!(h, 0);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use crate::atlas::attractor_matching::CityRecord;
|
||||
use crate::atlas::body_world_state::BodyWorldState;
|
||||
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::region_profile::BodyParams;
|
||||
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CityGenerationContext, QuarterWorldState};
|
||||
@@ -76,6 +77,11 @@ pub enum GenWorkItem {
|
||||
/// time. Drives Layer-3 TerritorialStatus + spatial character (#956,
|
||||
/// D-212/214/215). `None` → `FrontierUnclaimed`.
|
||||
dominant_faction: Option<String>,
|
||||
/// Body physical parameters for the RegionProfile layer (T-1023, D-239 §1).
|
||||
/// Pre-resolved at dispatch time. `None` → region layer skipped for this body.
|
||||
/// Boxed: `BodyParams` is large relative to other variants (clippy
|
||||
/// large_enum_variant) — boxing keeps `GenWorkItem` compact.
|
||||
body_params: Option<Box<BodyParams>>,
|
||||
},
|
||||
/// Generate a Phase 1 QuarterSkeleton for this city.
|
||||
///
|
||||
@@ -358,6 +364,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
body_seed,
|
||||
cities,
|
||||
dominant_faction,
|
||||
body_params,
|
||||
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
|
||||
Ok(hm) => {
|
||||
// Layer 1 runs at the GRID_W×GRID_H working resolution (D-202):
|
||||
@@ -367,16 +374,22 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
} else {
|
||||
hm
|
||||
};
|
||||
// Run through Layer 3 (settlement placement, #955): the enqueuer
|
||||
// pre-resolved this body's settlements onto `cities` and its
|
||||
// system faction onto `dominant_faction` (#956). A body with no
|
||||
// settlements yields empty placements at negligible cost.
|
||||
// Run through RegionProfile (T-1023, D-239 §1): includes Settlement
|
||||
// and all prior layers. RegionProfile > Settlement in CascadeLayer ord
|
||||
// so Settlement also runs when body_params is Some. When body_params
|
||||
// is None the cascade falls back to Settlement as the terminal layer.
|
||||
let up_to = if body_params.is_some() {
|
||||
CascadeLayer::RegionProfile
|
||||
} else {
|
||||
CascadeLayer::Settlement
|
||||
};
|
||||
let snapshot = run_cascade_from_heightmap(
|
||||
*body_seed,
|
||||
working,
|
||||
cities,
|
||||
dominant_faction.as_deref(),
|
||||
CascadeLayer::Settlement,
|
||||
body_params.as_deref(),
|
||||
up_to,
|
||||
);
|
||||
GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
@@ -480,6 +493,7 @@ mod tests {
|
||||
body_seed: SeedChain::for_body(42, body_id),
|
||||
cities: vec![],
|
||||
dominant_faction: None,
|
||||
body_params: None, // T-1023: no body params in queue-mechanic unit tests
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ pub fn handle_atlas_request(
|
||||
body_seed: SeedChain::for_body(world_seed, &req.body_id),
|
||||
cities,
|
||||
dominant_faction,
|
||||
body_params: None, // T-1023: body_params wired when DB reader is extended
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
@@ -243,6 +244,7 @@ mod tests {
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
regions: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
});
|
||||
let (_db, resolver) = empty_resolver();
|
||||
|
||||
@@ -9,6 +9,10 @@ pub mod body_world_state;
|
||||
pub mod cascade;
|
||||
pub mod city_context_reader;
|
||||
pub mod district_mix;
|
||||
// T-1026 foundation utility: covered by unit tests but not yet called from
|
||||
// production — its consumer is the T-1028 VoxelColumn pipeline (D-239 §4).
|
||||
#[allow(dead_code)]
|
||||
pub mod domain_warp;
|
||||
pub mod drainage;
|
||||
pub mod features;
|
||||
pub mod gen_queue;
|
||||
@@ -16,6 +20,7 @@ pub mod heightmap;
|
||||
pub mod layer1;
|
||||
pub mod layer_proxy;
|
||||
pub mod plugin;
|
||||
pub mod region_profile;
|
||||
pub mod skeleton_gen;
|
||||
pub mod source_resolver;
|
||||
pub mod subbiome;
|
||||
|
||||
@@ -246,6 +246,7 @@ mod tests {
|
||||
body_seed: SeedChain::for_body(42, "PlanetX"),
|
||||
cities: vec![],
|
||||
dominant_faction: None,
|
||||
body_params: None, // T-1023: no DB params in this unit test
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,9 @@ pub enum SeedDomain {
|
||||
Block = 5,
|
||||
/// NPC generation (keyed by NPC StableId).
|
||||
Npc = 6,
|
||||
/// Anti-squaring domain warp (D-239 §4, T-1026).
|
||||
/// Keyed by per-tile position id (see `atlas::domain_warp::pos_to_id`).
|
||||
DomainWarp = 7,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -249,6 +252,7 @@ mod tests {
|
||||
assert_eq!(SeedDomain::Layer4Quarter as u64, 4);
|
||||
assert_eq!(SeedDomain::Block as u64, 5);
|
||||
assert_eq!(SeedDomain::Npc as u64, 6);
|
||||
assert_eq!(SeedDomain::DomainWarp as u64, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,7 +56,7 @@ fn cascade_layer0_to_1_matches_golden() {
|
||||
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, &[], None, CascadeLayer::Topography);
|
||||
run_cascade_from_heightmap(body_seed, small, &[], None, None, CascadeLayer::Topography);
|
||||
let layer1 = snapshot.layer1.expect("Layer 1 ran");
|
||||
|
||||
let actual = json!({
|
||||
|
||||
@@ -480,6 +480,7 @@ COLUMN_MIGRATIONS = [
|
||||
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
|
||||
("brand_products", "price_tier", "TEXT"),
|
||||
("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable
|
||||
("bodies", "axial_tilt_deg", "REAL"), # T-1024, D-239 §2 — axial tilt from body-def frontmatter
|
||||
("meta", "schema_sha", "TEXT"),
|
||||
("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37)
|
||||
("system_economy", "economic_specialization", "TEXT"), # D-237 — authored specialization layer
|
||||
@@ -1279,6 +1280,121 @@ def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
return len(updates)
|
||||
|
||||
|
||||
def populate_axial_tilt_deg(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Populate bodies.axial_tilt_deg from planet-gen body-def frontmatter (T-1024).
|
||||
|
||||
Reads wiki/star-systems/*/bodies/*/index.md YAML frontmatter and extracts
|
||||
``orbit.axial_tilt_deg``. Only updates rows where axial_tilt_deg IS NULL
|
||||
(preserves any future authoritative column writes).
|
||||
|
||||
Source: body_definition_parser.py writes ``orbit.axial_tilt_deg`` into each
|
||||
body's index.md during the planet-gen batch run. This function mirrors
|
||||
``populate_body_radius_km`` in structure.
|
||||
"""
|
||||
import yaml # stdlib-compatible subset via PyYAML if available, else manual parse
|
||||
|
||||
def _parse_frontmatter_yaml(text: str) -> dict:
|
||||
"""Extract YAML frontmatter block from a markdown file."""
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}
|
||||
end_idx = None
|
||||
for i, line in enumerate(lines[1:], 1):
|
||||
if line.strip() == "---":
|
||||
end_idx = i
|
||||
break
|
||||
if end_idx is None:
|
||||
return {}
|
||||
fm_text = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
result = yaml.safe_load(fm_text)
|
||||
return result if isinstance(result, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Check if yaml is available; if not, use manual extraction.
|
||||
try:
|
||||
import yaml as _yaml_check # noqa: F401
|
||||
has_yaml = True
|
||||
except ImportError:
|
||||
has_yaml = False
|
||||
|
||||
if not has_yaml:
|
||||
# Fallback: manual extraction of axial_tilt_deg from YAML frontmatter.
|
||||
# Scans for " axial_tilt_deg: <value>" under an "orbit:" block.
|
||||
def _parse_frontmatter_manual(text: str) -> dict:
|
||||
lines = text.split("\n")
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}
|
||||
in_orbit = False
|
||||
result: dict = {}
|
||||
for line in lines[1:]:
|
||||
if line.strip() == "---":
|
||||
break
|
||||
stripped = line.strip()
|
||||
if stripped == "orbit:":
|
||||
in_orbit = True
|
||||
continue
|
||||
if in_orbit:
|
||||
# Detect leaving the orbit block (non-indented key).
|
||||
if line and not line.startswith(" ") and not line.startswith("\t"):
|
||||
in_orbit = False
|
||||
elif stripped.startswith("axial_tilt_deg:"):
|
||||
_, _, val = stripped.partition(":")
|
||||
try:
|
||||
result["axial_tilt_deg"] = float(val.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _parse_frontmatter_yaml(text: str) -> dict: # type: ignore[misc]
|
||||
return _parse_frontmatter_manual(text)
|
||||
|
||||
# Find all body index.md files.
|
||||
body_dir_pattern = WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md"
|
||||
import glob as _glob
|
||||
body_files = sorted(_glob.glob(str(body_dir_pattern)))
|
||||
|
||||
# Build body_id → axial_tilt_deg mapping from frontmatter.
|
||||
tilt_map: dict[str, float] = {}
|
||||
for fpath in body_files:
|
||||
try:
|
||||
text = Path(fpath).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
fm = _parse_frontmatter_yaml(text)
|
||||
if not isinstance(fm, dict):
|
||||
continue
|
||||
body_id = fm.get("id")
|
||||
orbit = fm.get("orbit", {})
|
||||
if isinstance(orbit, dict):
|
||||
tilt = orbit.get("axial_tilt_deg")
|
||||
else:
|
||||
tilt = None
|
||||
if body_id and tilt is not None:
|
||||
try:
|
||||
tilt_map[str(body_id)] = float(tilt)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Get all bodies where axial_tilt_deg IS NULL and body_id is in tilt_map.
|
||||
rows = conn.execute(
|
||||
"SELECT body_id FROM bodies WHERE axial_tilt_deg IS NULL"
|
||||
).fetchall()
|
||||
|
||||
updates = []
|
||||
for (body_id,) in rows:
|
||||
if body_id in tilt_map:
|
||||
updates.append((tilt_map[body_id], body_id))
|
||||
|
||||
if not dry_run and updates:
|
||||
conn.executemany(
|
||||
"UPDATE bodies SET axial_tilt_deg = ? WHERE body_id = ?", updates
|
||||
)
|
||||
|
||||
return len(updates)
|
||||
|
||||
|
||||
# Atlas geometry index tables (D-191). These hold computed positions — city
|
||||
# centres, road/river/rail polylines, ocean/mountain extents. Under D-223 the
|
||||
# Python atlas geometry generator was retired (#951); the deterministic
|
||||
@@ -2414,6 +2530,11 @@ def main():
|
||||
n_bias = populate_atlas_body_trait_bias(conn, args.dry_run)
|
||||
print(f" {n_bias} body trait-bias rows")
|
||||
|
||||
# 16. axial_tilt_deg from body-def frontmatter (T-1024, D-239 §2)
|
||||
print(" [16/16] Populating axial_tilt_deg from body-def frontmatter...")
|
||||
n_tilt = populate_axial_tilt_deg(conn, args.dry_run)
|
||||
print(f" {n_tilt} bodies updated with axial_tilt_deg")
|
||||
|
||||
# Validate structural integrity (FK, chain refs, chain completeness).
|
||||
# These errors indicate broken imported data — do NOT commit.
|
||||
print("\n Validating structural integrity...")
|
||||
|
||||
Reference in New Issue
Block a user