feat(simulation): dispatch RegionProfile layer in production (T-1032)
The D-239 carrier layer (T-1023/1024/1026) only ran in tests because every production AnalyzeBody enqueue passed body_params: None. Wire the real path: - New BodyParamsReader (server/src/atlas/body_params_reader.rs): read-only systems.db reader, joins bodies -> star_systems (LEFT JOIN) for the climate/ tectonic inputs. SQL verified against systems-schema.sql. All fields Option, NULLs handled; tectonic_activity absent from schema -> None (derives from planet_class). 5 unit tests. - layer_proxy.rs: on cache miss, read the body's params and pass Some(Box::new(..)). On read error, warn + fall back to None (cascade stops at Settlement, no panic) — graceful degradation. - plugin.rs / main.rs: register BodyParamsReaderResource (CityContextReader pattern) and thread it through serve_atlas_requests. BodyWorldState.regions now populates for real bodies in the D-206 background pass. End-to-end tests cover wired (regions populated) + unwired (empty) paths. cargo test 1504 pass, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
//! Pre-dispatch reader for body physical parameters (T-1032, D-239 §1).
|
||||
//!
|
||||
//! Reads the columns needed to construct a [`BodyParams`] for the RegionProfile
|
||||
//! 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_regions`.
|
||||
//!
|
||||
//! **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` |
|
||||
//! | `orbital_period_days` | `bodies.orbital_period_days` |
|
||||
//! | `axial_tilt_deg` | `bodies.axial_tilt_deg` |
|
||||
//! | `spectral_class` | `star_systems.spectral_class` (via `bodies.system_id`) |
|
||||
//! | `star_type` | `star_systems.star_type` (via `bodies.system_id`) |
|
||||
//!
|
||||
//! `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-region fields `region_latitude_deg` and `elevation_km`
|
||||
//! are set by `derive_all_regions` / `derive_region_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::region_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`.
|
||||
///
|
||||
/// Joins `bodies` → `star_systems` (LEFT JOIN, so a body with no system row
|
||||
/// still returns valid params with `spectral_class` and `star_type` = `None`).
|
||||
///
|
||||
/// 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; query row presence is what signals UnknownBody.
|
||||
// `tectonic_activity` is absent from the current schema — leave that
|
||||
// BodyParams field None (derives from planet_class at query time).
|
||||
let result: rusqlite::Result<(
|
||||
Option<String>, // b.hydrosphere
|
||||
Option<String>, // b.atmosphere
|
||||
Option<String>, // b.planet_class
|
||||
Option<f64>, // b.orbital_period_days
|
||||
Option<f64>, // b.axial_tilt_deg
|
||||
Option<String>, // s.spectral_class
|
||||
Option<String>, // s.star_type
|
||||
)> = conn.query_row(
|
||||
"SELECT
|
||||
b.hydrosphere,
|
||||
b.atmosphere,
|
||||
b.planet_class,
|
||||
b.orbital_period_days,
|
||||
b.axial_tilt_deg,
|
||||
s.spectral_class,
|
||||
s.star_type
|
||||
FROM bodies AS b
|
||||
LEFT JOIN star_systems AS s ON s.system_id = b.system_id
|
||||
WHERE b.body_id = ?1",
|
||||
[body_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((
|
||||
hydrosphere,
|
||||
atmosphere,
|
||||
planet_class,
|
||||
orbital_period_days,
|
||||
axial_tilt_deg,
|
||||
spectral_class,
|
||||
star_type,
|
||||
)) => {
|
||||
Ok(BodyParams {
|
||||
hydrosphere,
|
||||
atmosphere,
|
||||
planet_class,
|
||||
orbital_period_days,
|
||||
axial_tilt_deg,
|
||||
spectral_class,
|
||||
star_type,
|
||||
// tectonic_activity not in schema — leave None.
|
||||
tectonic_activity: None,
|
||||
// Per-region fields are set by derive_all_regions / derive_region_profile,
|
||||
// not at the body level. Leave at struct defaults (0.0).
|
||||
region_latitude_deg: 0.0,
|
||||
elevation_km: 0.0,
|
||||
})
|
||||
}
|
||||
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);
|
||||
|
||||
fn make_test_db(
|
||||
body_id: &str,
|
||||
system_id: &str,
|
||||
hydrosphere: Option<&str>,
|
||||
atmosphere: Option<&str>,
|
||||
planet_class: Option<&str>,
|
||||
orbital_period_days: Option<f64>,
|
||||
axial_tilt_deg: Option<f64>,
|
||||
spectral_class: Option<&str>,
|
||||
star_type: Option<&str>,
|
||||
) -> 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 star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![system_id, spectral_class, star_type],
|
||||
)
|
||||
.expect("insert system");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg],
|
||||
)
|
||||
.expect("insert body");
|
||||
|
||||
drop(conn);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_columns_present() {
|
||||
let db = make_test_db(
|
||||
"GJ1c",
|
||||
"GJ-1",
|
||||
Some("ocean"),
|
||||
Some("breathable"),
|
||||
Some("temperate"),
|
||||
Some(365.25),
|
||||
Some(23.5),
|
||||
Some("G"),
|
||||
Some("main_sequence"),
|
||||
);
|
||||
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!((params.orbital_period_days.unwrap() - 365.25).abs() < 1e-9);
|
||||
assert!((params.axial_tilt_deg.unwrap() - 23.5).abs() < 1e-9);
|
||||
assert_eq!(params.spectral_class.as_deref(), Some("G"));
|
||||
assert_eq!(params.star_type.as_deref(), Some("main_sequence"));
|
||||
// Per-region fields always start at 0.0 from the reader.
|
||||
assert_eq!(params.region_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", "GJ-2", None, None, None, None, None, None, None);
|
||||
let reader = BodyParamsReader::open(&db).expect("open");
|
||||
let params = reader.read_body_params("GJ2b").expect("read");
|
||||
|
||||
// All nullable columns → all None; struct defaults for per-region fields.
|
||||
assert!(params.hydrosphere.is_none());
|
||||
assert!(params.atmosphere.is_none());
|
||||
assert!(params.planet_class.is_none());
|
||||
assert!(params.orbital_period_days.is_none());
|
||||
assert!(params.axial_tilt_deg.is_none());
|
||||
assert!(params.spectral_class.is_none());
|
||||
assert!(params.star_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_body_returns_error() {
|
||||
let db = make_test_db("GJ3c", "GJ-3", None, None, None, 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 body_without_system_row_returns_null_stellar_fields() {
|
||||
// Body has no system_id — LEFT JOIN produces NULL for star_systems columns.
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_bpr_ns_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let conn = Connection::open(&path).expect("create db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
// Insert body with no system_id (no system row exists).
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, atmosphere) VALUES ('lonely', NULL, 'thin')",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
drop(conn);
|
||||
|
||||
let reader = BodyParamsReader::open(&path).expect("open");
|
||||
let params = reader.read_body_params("lonely").expect("read");
|
||||
assert_eq!(params.atmosphere.as_deref(), Some("thin"));
|
||||
// No system row → stellar fields NULL from LEFT JOIN.
|
||||
assert!(params.spectral_class.is_none());
|
||||
assert!(params.star_type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_is_deterministic() {
|
||||
let db = make_test_db(
|
||||
"GJ4d",
|
||||
"GJ-4",
|
||||
Some("ice"),
|
||||
Some("thin"),
|
||||
Some("frozen"),
|
||||
Some(200.0),
|
||||
Some(15.0),
|
||||
Some("K"),
|
||||
Some("main_sequence"),
|
||||
);
|
||||
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.orbital_period_days, p2.orbital_period_days);
|
||||
assert_eq!(p1.axial_tilt_deg, p2.axial_tilt_deg);
|
||||
assert_eq!(p1.spectral_class, p2.spectral_class);
|
||||
assert_eq!(p1.star_type, p2.star_type);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::body_params_reader::BodyParamsReader;
|
||||
use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick};
|
||||
use crate::atlas::cascade::CascadeLayer;
|
||||
use crate::atlas::city_context_reader::CityContextReader;
|
||||
@@ -64,12 +65,20 @@ pub struct AtlasLayerResponse {
|
||||
/// `city_reader` supplies the body's settlements for Layer-3 placement (#955),
|
||||
/// read on a cache miss. `None` (or a read failure) places no cities — the
|
||||
/// cascade still runs Layer 1; the body just gets no settlement placements.
|
||||
///
|
||||
/// `body_params_reader` supplies the body's physical parameters for the
|
||||
/// RegionProfile carrier layer (T-1032, D-239 §1), read on a cache miss.
|
||||
/// `None` (or a read failure) passes `body_params: None` to the work item,
|
||||
/// causing the cascade to stop at `CascadeLayer::Settlement` (pre-T-1032
|
||||
/// behaviour). A successful read passes `Some(Box::new(params))`, enabling
|
||||
/// the full `CascadeLayer::RegionProfile` path.
|
||||
pub fn handle_atlas_request(
|
||||
req: &AtlasLayerRequest,
|
||||
cache: &mut BodyWorldStateCache,
|
||||
queue: &GenerationQueue,
|
||||
resolver: &BodySourceResolver,
|
||||
city_reader: Option<&CityContextReader>,
|
||||
body_params_reader: Option<&BodyParamsReader>,
|
||||
world_seed: u64,
|
||||
current_tick: SimTick,
|
||||
) -> AtlasLayerResponse {
|
||||
@@ -124,6 +133,24 @@ pub fn handle_atlas_request(
|
||||
}
|
||||
None => (Vec::new(), None),
|
||||
};
|
||||
// Pre-resolve body physical params so the Rayon work item stays
|
||||
// DB-free (D-225 pattern). Read failures are non-fatal: log and
|
||||
// fall back to None (cascade stops at Settlement, pre-T-1032
|
||||
// behaviour, rather than aborting the entire analysis).
|
||||
let body_params = match body_params_reader {
|
||||
Some(reader) => reader
|
||||
.read_body_params(&req.body_id)
|
||||
.map(|p| Some(Box::new(p)))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
body_id = %req.body_id,
|
||||
error = %e,
|
||||
"body_params read failed; region layer skipped"
|
||||
);
|
||||
None
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
queue.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: req.body_id.clone(),
|
||||
@@ -132,7 +159,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
|
||||
body_params,
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
@@ -250,7 +277,16 @@ mod tests {
|
||||
let (_db, resolver) = empty_resolver();
|
||||
let queue = GenerationQueue::with_threads(1);
|
||||
|
||||
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1);
|
||||
let resp = handle_atlas_request(
|
||||
&req("GJ1c"),
|
||||
&mut cache,
|
||||
&queue,
|
||||
&resolver,
|
||||
None,
|
||||
None,
|
||||
42,
|
||||
1,
|
||||
);
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Ready);
|
||||
assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c");
|
||||
}
|
||||
@@ -261,7 +297,16 @@ mod tests {
|
||||
let (_db, resolver) = resolver_with_body("GJ1c");
|
||||
let queue = GenerationQueue::with_threads(1);
|
||||
|
||||
let resp = handle_atlas_request(&req("GJ1c"), &mut cache, &queue, &resolver, None, 42, 1);
|
||||
let resp = handle_atlas_request(
|
||||
&req("GJ1c"),
|
||||
&mut cache,
|
||||
&queue,
|
||||
&resolver,
|
||||
None,
|
||||
None,
|
||||
42,
|
||||
1,
|
||||
);
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||||
assert!(resp.layer1.is_none());
|
||||
|
||||
@@ -282,7 +327,154 @@ mod tests {
|
||||
let (_db, resolver) = empty_resolver();
|
||||
let queue = GenerationQueue::with_threads(1);
|
||||
|
||||
let resp = handle_atlas_request(&req("ghost"), &mut cache, &queue, &resolver, None, 42, 1);
|
||||
let resp = handle_atlas_request(
|
||||
&req("ghost"),
|
||||
&mut cache,
|
||||
&queue,
|
||||
&resolver,
|
||||
None,
|
||||
None,
|
||||
42,
|
||||
1,
|
||||
);
|
||||
assert_eq!(resp.status, AtlasLayerStatus::NotFound);
|
||||
}
|
||||
|
||||
/// Build a DB with the columns needed by both `BodySourceResolver` and
|
||||
/// `BodyParamsReader` for the same body, plus a tiny heightmap root.
|
||||
///
|
||||
/// Returns (db_path, resolver, body_params_reader, _root_kept_alive).
|
||||
fn resolver_and_params_reader(
|
||||
body_id: &str,
|
||||
) -> (
|
||||
PathBuf,
|
||||
BodySourceResolver,
|
||||
crate::atlas::body_params_reader::BodyParamsReader,
|
||||
PathBuf, // root dir — must stay alive for the test duration
|
||||
) {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let db = std::env::temp_dir().join(format!("sr_proxybp_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db);
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT,
|
||||
terrain_reference TEXT,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES ('GJ-1', 'G', 'main_sequence')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg)
|
||||
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 365.25, 23.5)",
|
||||
rusqlite::params![body_id, REL],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let root = std::env::temp_dir().join(format!("sr_proxybproot_{}_{n}", std::process::id()));
|
||||
write_tiny_heightmap(&root.join(REL));
|
||||
|
||||
let resolver = BodySourceResolver::open(&db, vec![root.clone()]).unwrap();
|
||||
let params_reader = crate::atlas::body_params_reader::BodyParamsReader::open(&db).unwrap();
|
||||
|
||||
(db, resolver, params_reader, root)
|
||||
}
|
||||
|
||||
/// With body_params_reader wired, a cache miss enqueues an AnalyzeBody that
|
||||
/// completes with populated `regions` (RegionProfile layer ran).
|
||||
#[test]
|
||||
fn body_params_reader_wired_produces_populated_regions() {
|
||||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||||
let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||||
let queue = GenerationQueue::with_threads(1);
|
||||
|
||||
let resp = handle_atlas_request(
|
||||
&req("GJ1c"),
|
||||
&mut cache,
|
||||
&queue,
|
||||
&resolver,
|
||||
None,
|
||||
Some(¶ms_reader),
|
||||
42,
|
||||
1,
|
||||
);
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||||
|
||||
// Wait for the Rayon work item to complete.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let completions = queue.drain_completions();
|
||||
let body_state = completions
|
||||
.into_iter()
|
||||
.find_map(|c| {
|
||||
if let GenCompletion::BodyAnalyzed { body_id, state } = c {
|
||||
if body_id == "GJ1c" {
|
||||
return Some(state);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.expect("AnalyzeBody must complete for GJ1c");
|
||||
|
||||
assert!(
|
||||
!body_state.regions.is_empty(),
|
||||
"regions must be populated when body_params_reader is wired (T-1032 dispatch path)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without body_params_reader (None), regions is empty — pre-T-1032 behaviour.
|
||||
#[test]
|
||||
fn no_body_params_reader_leaves_regions_empty() {
|
||||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||||
let (_db, resolver) = resolver_with_body("GJ1c");
|
||||
let queue = GenerationQueue::with_threads(1);
|
||||
|
||||
let resp = handle_atlas_request(
|
||||
&req("GJ1c"),
|
||||
&mut cache,
|
||||
&queue,
|
||||
&resolver,
|
||||
None,
|
||||
None, // no body_params_reader
|
||||
42,
|
||||
1,
|
||||
);
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let completions = queue.drain_completions();
|
||||
let body_state = completions
|
||||
.into_iter()
|
||||
.find_map(|c| {
|
||||
if let GenCompletion::BodyAnalyzed { body_id, state } = c {
|
||||
if body_id == "GJ1c" {
|
||||
return Some(state);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.expect("AnalyzeBody must complete for GJ1c");
|
||||
|
||||
assert!(
|
||||
body_state.regions.is_empty(),
|
||||
"regions must remain empty when no body_params_reader is wired"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod attractor_matching;
|
||||
pub mod block_irregularity;
|
||||
pub mod body_params_reader;
|
||||
pub mod body_world_state;
|
||||
pub mod cascade;
|
||||
pub mod city_context_reader;
|
||||
|
||||
@@ -12,6 +12,7 @@ use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::body_params_reader::BodyParamsReaderResource;
|
||||
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
|
||||
use crate::atlas::city_context_reader::{
|
||||
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
|
||||
@@ -50,6 +51,7 @@ fn serve_atlas_requests(
|
||||
queue: Res<GenerationQueue>,
|
||||
resolver: Option<Res<BodySourceResolverResource>>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
body_params_reader: Option<Res<BodyParamsReaderResource>>,
|
||||
rng: Option<Res<SimRng>>,
|
||||
time: Option<Res<SimulationTime>>,
|
||||
) {
|
||||
@@ -59,12 +61,20 @@ fn serve_atlas_requests(
|
||||
let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0);
|
||||
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
|
||||
let reader = city_reader.as_ref().map(|r| &r.0);
|
||||
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
|
||||
let pending: Vec<_> = requests.0.drain(..).collect();
|
||||
for req in pending {
|
||||
let resp = match resolver.as_ref() {
|
||||
Some(r) => {
|
||||
handle_atlas_request(&req, &mut cache, &queue, &r.0, reader, world_seed, tick)
|
||||
}
|
||||
Some(r) => handle_atlas_request(
|
||||
&req,
|
||||
&mut cache,
|
||||
&queue,
|
||||
&r.0,
|
||||
reader,
|
||||
params_reader,
|
||||
world_seed,
|
||||
tick,
|
||||
),
|
||||
None => AtlasLayerResponse {
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
|
||||
|
||||
@@ -215,6 +215,24 @@ fn main() {
|
||||
),
|
||||
}
|
||||
|
||||
// Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1):
|
||||
// reads hydrosphere / atmosphere / planet_class / orbital_period_days /
|
||||
// axial_tilt_deg / spectral_class / star_type on a cache miss so the Rayon
|
||||
// cascade work item stays DB-free (D-225 pattern).
|
||||
match settled_reach_server::atlas::body_params_reader::BodyParamsReader::open(&systems_db_path)
|
||||
{
|
||||
Ok(reader) => {
|
||||
tracing::info!("Body params reader opened: {:?}", systems_db_path);
|
||||
app.insert_resource(
|
||||
settled_reach_server::atlas::body_params_reader::BodyParamsReaderResource(reader),
|
||||
);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"Body params reader unavailable ({}). RegionProfile layer will be skipped.",
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
// Initialize SQLite settings store (#627).
|
||||
// Path: alongside save files in the server's working directory.
|
||||
let settings_path = std::path::PathBuf::from("settings.db");
|
||||
|
||||
Reference in New Issue
Block a user