Files
settled-reach/server/src/atlas/layer_proxy.rs
T
jpmschweitzerandClaude Opus 4.8 0263b68df9 feat(simulation): cache TerrainAnalysis + derive basin_direction from the D8 thalweg (T-1044, T-1047)
T-1044: run_layer1 now returns TerrainAnalysis (carried transiently on
CascadeSnapshot, dropped after the district + road-graph passes), eliminating the
redundant per-body drainage::analyze + TerrainAnalysis::analyze re-run flagged by
PERF/TODO(T-1044). Not persisted on the LRU-cached state (D-203/T-1048 size concern).

T-1047: basin_direction is now derived from the real D8 thalweg. run_layer1
aggregates a per-district dominant D8 direction from the live fdir grid (carried
transiently on DrainageResult), threaded via Layer1Output.district_basin_dirs ->
derive_all_districts -> DistrictProfile.basin_direction; derive_chunk_context reads
it directly. Removed the false derive_basin_direction (it branched on ocean_fraction_q
then read seed bits despite a doc comment claiming an elev_q/slope_q D8 proxy) +
corrected the module contract. D-239 §8 (D8 thalweg) now actually honoured.

1559 tests pass; golden byte-identical (district_basin_dirs is #[serde(skip)], transient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:46:42 +02:00

604 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Atlas layer-stream proxy handler (#969, D-225).
//!
//! Serves a body's generation-cascade layer data to the client, compute-on-
//! demand and mod-first:
//! - **Cache hit** → serialize the cached `Layer1Output` and reply `Ready`.
//! - **Cache miss** → resolve the body's source heightmap ([`BodySourceResolver`]),
//! enqueue an `Immediate` `AnalyzeBody` on the background queue (#968), and
//! reply `Pending` (the client re-requests; the drain system populates the
//! cache, so a later request hits).
//!
//! Pure handler logic; the bridge wiring (message routing) is the proxy's other
//! half. No baking — the heightmap is the only source of truth (D-225).
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;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer1::Layer1Output;
use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError};
use crate::seed::SeedChain;
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
/// (the loader prefers the chunk; this is only the floor).
const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// A client request for a body's generation layers (D-225).
///
/// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item`
/// currently runs the cascade through `CascadeLayer::Settlement` unconditionally,
/// ignoring this field. Wiring per-request depth (and the partial caching it
/// implies) is deferred to #1021.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerRequest {
pub body_id: String,
pub up_to: CascadeLayer,
}
/// Status of a layer response (D-225).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AtlasLayerStatus {
/// Layer data is ready (`layer1` is populated).
Ready,
/// Analysis was enqueued; the client should re-request shortly.
Pending,
/// The body is unknown or has no source terrain — re-requesting won't help.
NotFound,
/// Resolution / IO failure (message for the client log).
Error(String),
}
/// The cascade's coarse district grid, surfaced for the Atlas generation overlay
/// (T-1046, D-226). This is the **planetary-scale** view — one cell per coarse
/// grid square (`grid_w/cols` heightmap pixels) — not the on-demand 2 km districts
/// (those derive only when a player enters a settlement, Phase 5). `morphology`
/// and `elev_q` are row-major (`rows × cols`); `morphology[i]` is a `MorphologyZone`
/// discriminant (D-239 §6, `repr(u8)`), `elev_q[i]` is 0100 elevation for relief
/// shading. The client maps `cols × rows` onto the displayed heightmap.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DistrictGridLayer {
pub cols: u32,
pub rows: u32,
pub morphology: Vec<u8>,
pub elev_q: Vec<u8>,
}
/// A layer response: the computed `Layer1Output` + the coarse district grid
/// (D-225, T-1046), or a non-ready status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerResponse {
pub body_id: String,
pub status: AtlasLayerStatus,
pub layer1: Option<Layer1Output>,
/// The coarse district/morphology grid for the Atlas overlay (T-1046).
/// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise.
pub district_grid: Option<DistrictGridLayer>,
}
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
/// Returns `None` when the DistrictProfile layer has not run (empty `districts`).
/// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full
/// heightmap), so the extent comes from the maximum `DistrictPos`.
pub fn build_district_grid(
state: &crate::atlas::body_world_state::BodyWorldState,
) -> Option<DistrictGridLayer> {
if state.districts.is_empty() {
return None;
}
let cols = state.districts.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1;
let rows = state.districts.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1;
let n = (cols * rows) as usize;
let mut morphology = vec![0u8; n];
let mut elev_q = vec![0u8; n];
for (&(x, y), profile) in &state.districts {
if x < 0 || y < 0 {
continue;
}
let i = (y as u32 * cols + x as u32) as usize;
if i < n {
morphology[i] = profile.morphology_zone as u8;
elev_q[i] = profile.elev_q.clamp(0, 100) as u8;
}
}
Some(DistrictGridLayer {
cols,
rows,
morphology,
elev_q,
})
}
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
/// `world_seed` derives the body's `SeedChain` for the enqueued analysis.
///
/// `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
/// DistrictProfile 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::DistrictProfile` 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 {
// Cache hit — serve immediately.
if let Some(state) = cache.get(&req.body_id, current_tick) {
let layer1 = Layer1Output {
body_id: state.body_id.clone(),
river_network: state.river_network.clone(),
drainage_basins: state.drainage_basins.clone(),
attractors: state.attractors.clone(),
// The cascade ran on the downsampled heightmap, so its dims are the
// working grid all Layer-1 positions are expressed in (#960).
grid_w: state.heightmap_width,
grid_h: state.heightmap_height,
// district_basin_dirs is transient — it is aggregated during run_layer1
// and consumed by derive_all_districts before being stored on
// BodyWorldState. When reconstructing Layer1Output from the cache for
// the client response, the per-district direction is already encoded in
// DistrictProfile.basin_direction (BodyWorldState.districts) and is not
// needed again here. Supply an empty map.
district_basin_dirs: std::collections::BTreeMap::new(),
};
let district_grid = build_district_grid(state);
return AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Ready,
layer1: Some(layer1),
district_grid,
};
}
// Miss — resolve the source heightmap and enqueue background analysis.
match resolver.resolve(&req.body_id) {
Ok(heightmap_path) => {
// Pre-resolve this body's settlements + system faction so the Rayon
// work item stays DB-free (#955/#956, D-225). Read failures are
// non-fatal: log and fall back (no cities / no faction → frontier).
let (cities, dominant_faction) = match city_reader {
Some(reader) => {
let cities = reader
.read_body_settlements(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"settlement read failed; placing no cities"
);
Vec::new()
});
let faction = reader
.read_body_dominant_faction(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"dominant_faction read failed; defaulting to frontier"
);
None
});
(cities, faction)
}
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; district layer skipped"
);
None
}),
None => None,
};
queue.submit(
GenWorkItem::AnalyzeBody {
body_id: req.body_id.clone(),
heightmap_path,
sea_level: DEFAULT_SEA_LEVEL,
body_seed: SeedChain::for_body(world_seed, &req.body_id),
cities,
dominant_faction,
body_params,
},
GenPriority::Immediate,
);
AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Pending,
layer1: None,
district_grid: None,
}
}
// Unknown / no terrain → re-requesting won't help.
Err(SourceResolveError::UnknownBody(_))
| Err(SourceResolveError::NoTerrainReference { .. }) => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::NotFound,
layer1: None,
district_grid: None,
},
Err(e) => AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Error(e.to_string()),
layer1: None,
district_grid: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork, CACHE_CAPACITY};
use crate::atlas::gen_queue::GenCompletion;
use rusqlite::Connection;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
static SEQ: AtomicU32 = AtomicU32::new(0);
#[test]
fn district_grid_built_from_cached_districts() {
use crate::atlas::district_profile::{
DistrictProfile, GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
};
use crate::simulation::generator::MorphologyZone;
let dp = |zone: MorphologyZone, elev: i32| DistrictProfile {
morphology_zone: zone,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
precipitation_class: PrecipitationClass::Arid,
slope_q: 0,
elev_q: elev,
ocean_fraction_q: 0,
river_threshold: 200,
temperature_c: Some(10.0),
moisture_q: 50,
vegetation_class: VegetationClass::Barren,
basin_direction: crate::atlas::scale::BasinDirection::default(),
};
let mut state = BodyWorldState {
body_id: "GJ1c".into(),
heightmap: vec![],
heightmap_width: 16,
heightmap_height: 8,
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
districts: std::collections::BTreeMap::new(),
last_accessed: 0,
};
// 3×2 grid with two distinct zones at the corners.
state
.districts
.insert((0, 0), dp(MorphologyZone::AlluvialPlain, 10));
state
.districts
.insert((2, 1), dp(MorphologyZone::Alpine, 90));
let grid = build_district_grid(&state).expect("districts present → Some grid");
assert_eq!((grid.cols, grid.rows), (3, 2));
assert_eq!(grid.morphology.len(), 6);
assert_eq!(grid.morphology[0], MorphologyZone::AlluvialPlain as u8);
assert_eq!(
grid.morphology[grid.cols as usize + 2],
MorphologyZone::Alpine as u8
);
assert_eq!(grid.elev_q[grid.cols as usize + 2], 90);
// Empty districts → None (DistrictProfile layer hasn't run).
state.districts.clear();
assert!(build_district_grid(&state).is_none());
}
fn req(body_id: &str) -> AtlasLayerRequest {
AtlasLayerRequest {
body_id: body_id.to_string(),
up_to: CascadeLayer::Topography,
}
}
const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
/// systems.db with one bodies row, + a base root containing a tiny 16-bit
/// heightmap PNG at the body's terrain_reference. Returns (db, resolver).
fn resolver_with_body(body_id: &str) -> (PathBuf, BodySourceResolver) {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let db = std::env::temp_dir().join(format!("sr_proxy_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&db);
let conn = Connection::open(&db).unwrap();
conn.execute(
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, terrain_reference TEXT)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO bodies (body_id, terrain_reference) VALUES (?1, ?2)",
rusqlite::params![body_id, REL],
)
.unwrap();
let root = std::env::temp_dir().join(format!("sr_proxyroot_{}_{n}", std::process::id()));
write_tiny_heightmap(&root.join(REL));
let resolver = BodySourceResolver::open(&db, vec![root]).unwrap();
(db, resolver)
}
fn write_tiny_heightmap(path: &Path) {
use std::io::BufWriter;
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16);
enc.set_color(png::ColorType::Grayscale);
enc.set_depth(png::BitDepth::Sixteen);
let mut w = enc.write_header().unwrap();
let data: Vec<u8> = (0..32u32 * 16)
.flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes())
.collect();
w.write_image_data(&data).unwrap();
}
fn empty_resolver() -> (PathBuf, BodySourceResolver) {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let db = std::env::temp_dir().join(format!("sr_proxye_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&db);
let conn = Connection::open(&db).unwrap();
conn.execute(
"CREATE TABLE bodies (body_id TEXT, terrain_reference TEXT)",
[],
)
.unwrap();
let resolver = BodySourceResolver::open(&db, vec![std::env::temp_dir()]).unwrap();
(db, resolver)
}
#[test]
fn cache_hit_is_ready() {
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
cache.insert(BodyWorldState {
body_id: "GJ1c".into(),
heightmap: vec![0.0; 4],
heightmap_width: 2,
heightmap_height: 2,
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
districts: std::collections::BTreeMap::new(),
last_accessed: 0,
});
let (_db, resolver) = empty_resolver();
let queue = GenerationQueue::with_threads(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");
}
#[test]
fn cache_miss_enqueues_and_pends_then_analyzes() {
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,
42,
1,
);
assert_eq!(resp.status, AtlasLayerStatus::Pending);
assert!(resp.layer1.is_none());
// The enqueued analysis runs the real cascade and completes.
std::thread::sleep(Duration::from_millis(150));
let completions = queue.drain_completions();
assert!(
completions.iter().any(
|c| matches!(c, GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "GJ1c")
),
"miss should enqueue an AnalyzeBody that completes: {completions:?}"
);
}
#[test]
fn unknown_body_is_not_found() {
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
let (_db, resolver) = empty_resolver();
let queue = GenerationQueue::with_threads(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,
body_radius_km REAL,
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, body_radius_km, orbital_period_days, axial_tilt_deg)
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 6371.0, 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 `districts` (DistrictProfile 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(&params_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.districts.is_empty(),
"districts must be populated when body_params_reader is wired (T-1032 dispatch path)"
);
}
/// Without body_params_reader (None), districts 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.districts.is_empty(),
"districts must remain empty when no body_params_reader is wired"
);
}
}