Phase 2 of the D-243 re-scale. The ~1km carrier is renamed and re-scaled to
the 2km district, and every consumer reads scale from the canonical scale.rs
instead of a local literal.
- region_profile.rs -> district_profile.rs; RegionProfile -> DistrictProfile,
RegionPos -> DistrictPos (re-exported from scale::DistrictPos), derive_*_regions
-> derive_*_districts, across all 10 consumers + the derivation harness.
- chunk_context now references scale:: for the carrier cell: district 1024m -> 2048m,
shift 4 -> 5 (CHUNK_DISTRICT_SHIFT). This caught a real latent bug: the seed-district
index used a literal '>> 4' while the anchor used the constant, so same-district
chunks could derive different anchors — now both use scale::CHUNK_DISTRICT_SHIFT.
- derivation_harness scale literals ('>> 4', '* 1024', '0..16') converted to
scale:: constants (the same drift the canonical source eliminates). Believability
tests pass at 2km; the voxel determinism golden re-pinned (anchors moved with the
re-scale — deterministic, intended).
Full suite + clippy --all-targets -D warnings green. The district DERIVATION still
tiles the heightmap at 8 cells (the old coarse path); replacing it with on-demand
heightmap interpolation + detail-scatter is phase 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! Pre-dispatch reader for body physical parameters (T-1032, D-239 §1).
|
|
//!
|
|
//! Reads the columns needed to construct a [`BodyParams`] for the DistrictProfile
|
|
//! carrier layer from `systems.db` at `AnalyzeBody` enqueue time. This keeps
|
|
//! the Rayon work item DB-free while supplying the climate/tectonic inputs
|
|
//! required by `derive_all_districts`.
|
|
//!
|
|
//! **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`,
|
|
//! `spectral_class`, `star_type`) are non-canonical placeholder data — they are
|
|
//! NOT read into `BodyParams`. Temperature derives from `planet_class` envelope
|
|
//! only. The DB columns are left in place (no schema change) but are no longer
|
|
//! selected here.
|
|
//!
|
|
//! **Columns queried** (all are nullable in the schema — `BodyParams` fields are
|
|
//! `Option`):
|
|
//!
|
|
//! | Field | Source |
|
|
//! |-------|--------|
|
|
//! | `hydrosphere` | `bodies.hydrosphere` |
|
|
//! | `atmosphere` | `bodies.atmosphere` |
|
|
//! | `planet_class` | `bodies.planet_class` |
|
|
//! | `body_radius_km` | `bodies.body_radius_km` (D-204 — the D-243 elastic-seam input) |
|
|
//!
|
|
//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity`
|
|
//! is left `None` so the derivation falls back to `planet_class` as documented
|
|
//! on the struct. The per-district fields `district_latitude_deg` and `elevation_km`
|
|
//! are set by `derive_all_districts` / `derive_district_profile`, not here; they
|
|
//! remain at their struct defaults (0.0) from this reader.
|
|
//!
|
|
//! Read-only `systems.db` access follows the same pattern as
|
|
//! [`crate::atlas::source_resolver::BodySourceResolver`] and
|
|
//! [`crate::atlas::city_context_reader::CityContextReader`].
|
|
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use rusqlite::{Connection, OpenFlags};
|
|
use thiserror::Error;
|
|
|
|
use crate::atlas::district_profile::BodyParams;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Errors from reading body physical parameters.
|
|
#[derive(Debug, Error)]
|
|
pub enum BodyParamsReadError {
|
|
#[error("systems.db error: {0}")]
|
|
Db(String),
|
|
#[error("body {0} not found in bodies table")]
|
|
UnknownBody(String),
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Reads body physical parameters from `systems.db` at `AnalyzeBody` dispatch
|
|
/// time. Holds a read-only SQLite connection.
|
|
///
|
|
/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`] —
|
|
/// pre-resolves inputs the Rayon work item needs before it is submitted,
|
|
/// keeping the cascade DB-free (D-225 pattern).
|
|
pub struct BodyParamsReader {
|
|
conn: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
impl BodyParamsReader {
|
|
/// Open a read-only connection to `systems_db`.
|
|
pub fn open(systems_db: &Path) -> Result<Self, BodyParamsReadError> {
|
|
let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
|
.map_err(|e| BodyParamsReadError::Db(e.to_string()))?;
|
|
Ok(Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
})
|
|
}
|
|
|
|
/// Read the physical parameters for `body_id`.
|
|
///
|
|
/// D-240: only `hydrosphere`, `atmosphere`, and `planet_class` are selected.
|
|
/// The orbit/star columns (`orbital_period_days`, `axial_tilt_deg`,
|
|
/// `spectral_class`, `star_type`) remain in the DB schema but are not
|
|
/// consumed — they are non-canonical placeholder data per D-240.
|
|
///
|
|
/// Returns `BodyParamsReadError::UnknownBody` if the body is not in the DB.
|
|
/// A body that exists but has all-NULL columns still returns `Ok(BodyParams::default())` —
|
|
/// every derivation function handles missing fields gracefully.
|
|
pub fn read_body_params(&self, body_id: &str) -> Result<BodyParams, BodyParamsReadError> {
|
|
let conn = self
|
|
.conn
|
|
.lock()
|
|
.map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?;
|
|
|
|
// All columns are nullable; row absence is what signals UnknownBody.
|
|
// `tectonic_activity` is absent from the current schema — leave that
|
|
// BodyParams field None (derives from planet_class at derivation time).
|
|
let result: rusqlite::Result<(
|
|
Option<String>, // b.hydrosphere
|
|
Option<String>, // b.atmosphere
|
|
Option<String>, // b.planet_class
|
|
Option<f64>, // b.body_radius_km (D-204 — the D-243 elastic seam input)
|
|
)> = conn.query_row(
|
|
"SELECT
|
|
b.hydrosphere,
|
|
b.atmosphere,
|
|
b.planet_class,
|
|
b.body_radius_km
|
|
FROM bodies AS b
|
|
WHERE b.body_id = ?1",
|
|
[body_id],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
|
);
|
|
|
|
match result {
|
|
Ok((hydrosphere, atmosphere, planet_class, body_radius_km)) => Ok(BodyParams {
|
|
hydrosphere,
|
|
atmosphere,
|
|
planet_class,
|
|
// tectonic_activity not in schema — leave None.
|
|
tectonic_activity: None,
|
|
// Per-district fields are set by derive_all_districts / derive_district_profile,
|
|
// not at the body level. Leave at struct defaults (0.0).
|
|
district_latitude_deg: 0.0,
|
|
elevation_km: 0.0,
|
|
body_radius_km,
|
|
}),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
Err(BodyParamsReadError::UnknownBody(body_id.to_string()))
|
|
}
|
|
Err(e) => Err(BodyParamsReadError::Db(e.to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bevy resource wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Bevy `Resource` wrapper — `Res<BodyParamsReaderResource>` in systems.
|
|
/// Mirrors [`crate::atlas::city_context_reader::CityContextReaderResource`];
|
|
/// the atlas proxy uses it to read a body's physical params on a cache miss.
|
|
#[derive(bevy_ecs::prelude::Resource)]
|
|
pub struct BodyParamsReaderResource(pub BodyParamsReader);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rusqlite::Connection;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
static SEQ: AtomicU32 = AtomicU32::new(0);
|
|
|
|
/// Create a minimal test DB with the three canonical columns only.
|
|
/// The orbit/star columns (orbital_period_days, axial_tilt_deg,
|
|
/// spectral_class, star_type) are intentionally absent — the reader
|
|
/// must not select them (D-240).
|
|
fn make_test_db(
|
|
body_id: &str,
|
|
hydrosphere: Option<&str>,
|
|
atmosphere: Option<&str>,
|
|
planet_class: Option<&str>,
|
|
body_radius_km: Option<f64>,
|
|
) -> PathBuf {
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!("sr_bpr_{}_{n}.db", std::process::id()));
|
|
let _ = std::fs::remove_file(&path);
|
|
let conn = Connection::open(&path).expect("create db");
|
|
conn.execute_batch(
|
|
"CREATE TABLE bodies (
|
|
body_id TEXT PRIMARY KEY,
|
|
hydrosphere TEXT,
|
|
atmosphere TEXT,
|
|
planet_class TEXT,
|
|
body_radius_km REAL
|
|
);",
|
|
)
|
|
.expect("create tables");
|
|
|
|
conn.execute(
|
|
"INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class, body_radius_km)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
body_id,
|
|
hydrosphere,
|
|
atmosphere,
|
|
planet_class,
|
|
body_radius_km
|
|
],
|
|
)
|
|
.expect("insert body");
|
|
|
|
drop(conn);
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn reads_all_columns_present() {
|
|
let db = make_test_db(
|
|
"GJ1c",
|
|
Some("ocean"),
|
|
Some("breathable"),
|
|
Some("temperate"),
|
|
Some(6371.0),
|
|
);
|
|
let reader = BodyParamsReader::open(&db).expect("open");
|
|
let params = reader.read_body_params("GJ1c").expect("read");
|
|
|
|
assert_eq!(params.hydrosphere.as_deref(), Some("ocean"));
|
|
assert_eq!(params.atmosphere.as_deref(), Some("breathable"));
|
|
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
|
|
assert_eq!(params.body_radius_km, Some(6371.0));
|
|
// Per-district fields always start at 0.0 from the reader.
|
|
assert_eq!(params.district_latitude_deg, 0.0);
|
|
assert_eq!(params.elevation_km, 0.0);
|
|
// tectonic_activity not in schema → None.
|
|
assert!(params.tectonic_activity.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn handles_all_null_columns() {
|
|
let db = make_test_db("GJ2b", None, None, None, None);
|
|
let reader = BodyParamsReader::open(&db).expect("open");
|
|
let params = reader.read_body_params("GJ2b").expect("read");
|
|
|
|
assert!(params.hydrosphere.is_none());
|
|
assert!(params.atmosphere.is_none());
|
|
assert!(params.planet_class.is_none());
|
|
assert!(params.body_radius_km.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_body_returns_error() {
|
|
let db = make_test_db("GJ3c", None, None, None, None);
|
|
let reader = BodyParamsReader::open(&db).expect("open");
|
|
let err = reader.read_body_params("ghost").expect_err("should fail");
|
|
assert!(matches!(err, BodyParamsReadError::UnknownBody(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn read_is_deterministic() {
|
|
let db = make_test_db(
|
|
"GJ4d",
|
|
Some("ice"),
|
|
Some("thin"),
|
|
Some("frozen"),
|
|
Some(3389.5),
|
|
);
|
|
let reader = BodyParamsReader::open(&db).expect("open");
|
|
let p1 = reader.read_body_params("GJ4d").expect("first read");
|
|
let p2 = reader.read_body_params("GJ4d").expect("second read");
|
|
|
|
assert_eq!(p1.hydrosphere, p2.hydrosphere);
|
|
assert_eq!(p1.atmosphere, p2.atmosphere);
|
|
assert_eq!(p1.planet_class, p2.planet_class);
|
|
assert_eq!(p1.body_radius_km, p2.body_radius_km);
|
|
assert_eq!(p1.body_radius_km, Some(3389.5));
|
|
}
|
|
|
|
#[test]
|
|
fn reader_no_longer_queries_orbit_star_columns() {
|
|
// Verify the reader works against a DB that has NO orbit/star columns at all
|
|
// (the columns are left in production schema but the SELECT must not touch them).
|
|
// This test intentionally omits those columns from the schema to prove
|
|
// the query doesn't reference them.
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let path =
|
|
std::env::temp_dir().join(format!("sr_bpr_nostar_{}_{n}.db", std::process::id()));
|
|
let _ = std::fs::remove_file(&path);
|
|
let conn = Connection::open(&path).expect("create");
|
|
conn.execute_batch(
|
|
"CREATE TABLE bodies (
|
|
body_id TEXT PRIMARY KEY,
|
|
hydrosphere TEXT,
|
|
atmosphere TEXT,
|
|
planet_class TEXT,
|
|
body_radius_km REAL
|
|
-- orbital_period_days, axial_tilt_deg, spectral_class, star_type
|
|
-- deliberately absent to prove SELECT doesn't reference them
|
|
);
|
|
INSERT INTO bodies VALUES ('X', 'ocean', 'breathable', 'temperate', 6371.0);",
|
|
)
|
|
.expect("setup");
|
|
drop(conn);
|
|
|
|
let reader = BodyParamsReader::open(&path).expect("open");
|
|
let params = reader.read_body_params("X").expect("read");
|
|
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
|
|
}
|
|
}
|