diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index 91f93680c..d5b8e8b23 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -ˆ§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file +‰§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ¯district_windowÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack index 0ea79bf10..bd81fb3cb 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -ˆ§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file +‰§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ¯district_windowÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack index bd8d0486c..e54a67dfa 100644 Binary files a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack and b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack differ diff --git a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack new file mode 100644 index 000000000..ccd64e073 Binary files /dev/null and b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack differ diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 614a28b6f..6b84008c8 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -14,6 +14,7 @@ //! - `AnalyzeBody`: D8 drainage + attractor extraction for a body. //! - `GenerateSkeleton`: Phase 1 QuarterSkeleton for a city. //! - `FillChunk`: Phase 2 chunk fill for a pre-loaded quarter. +//! - `DeriveWindow`: District-resolution window derive (D-226 T-1124, T-1137). //! //! Completion events are delivered to the main thread via //! `GenerationQueue::drain_completions()`, called once per tick from a Bevy @@ -30,11 +31,13 @@ use crossbeam_channel::{Receiver, Sender}; 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::district_profile::BodyParams; +use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::layer_proxy::{build_district_window_layer, DistrictWindowLayer}; use crate::atlas::shell::{fill_chunk, FilledChunk}; use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton}; use crate::atlas::trait_catalog_reader::ExteriorCatalog; +use crate::bridge::ConnectionId; use crate::seed::SeedChain; use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState}; @@ -148,12 +151,71 @@ pub enum GenWorkItem { /// this variant needs no `Box` to stay clippy `large_enum_variant`-clean. block_tags: Vec, }, + /// Derive a district-resolution window (D-226 T-1124 amendment, T-1137). + /// + /// **Binding serving model:** window derives ride this SAME Rayon queue as + /// every other expensive atlas path — never inline on the `PreInput` drain + /// (the amendment's §1 is explicit: a window derive at n=32/64 is + /// ~7–29 ms, which would blow the "cheap channel drain" contract + /// `drain_generation_completions` documents). + /// + /// **TerrainAnalysis availability (T-1137 binding decision, with numbers):** + /// `BodyWorldState` does NOT retain `TerrainAnalysis` after cascade + /// completion (T-1044 scoped its transient-carry fix to *within-cascade* + /// reuse only — `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` + /// finish; see the doc on `CascadeSnapshot::terrain_analysis`). Caching it + /// alongside every `BodyWorldStateCache` entry would cost ~2 MB × 50-body + /// capacity ≈ 100 MB of PERMANENT resident cost, paid by every cached body + /// whether or not a window is ever requested for it — the exact D-203 + /// budget concern T-1044's own ticket text guarded against. Re-deriving via + /// `run_layer1` per window-serving cache miss costs ~45 ms ONE-TIME, paid + /// only when a window is actually requested, and — because this work item + /// already runs off the tick thread on the Rayon queue — that cost is + /// invisible to the main thread; it is the same order of magnitude as one + /// window derive itself, not a multiplier on it. So: re-derive + /// (`heightmap_path`/`sea_level` below), matching `aliveness_probe`'s + /// existing `--render` workaround, NOT a `TerrainAnalysis` field cached on + /// `BodyWorldState`. + DeriveWindow { + body_id: String, + /// Coalescing/routing key (D-226 T-1124 amendment §1 "recommended" + /// per-connection coalescing) — NOT used by `run_work_item` itself + /// (the derive is connection-agnostic), only by + /// `GenerationQueue::submit_window` to decide which still-pending item + /// a new one for the same connection+body supersedes. + conn_id: ConnectionId, + /// Mod-resolved source heightmap PNG (mirrors `AnalyzeBody`). + heightmap_path: PathBuf, + sea_level: f32, + /// This body's `SeedChain` position — `derive_district`'s seed input. + body_seed: SeedChain, + /// Body physical parameters. Boxed for the same `large_enum_variant` + /// reason `AnalyzeBody.body_params` is boxed. + body_params: Box, + /// Window centre + side length in districts. `n` is ALREADY clamped to + /// `[1, DISTRICT_WINDOW_MAX_N]` by the caller (`handle_atlas_request`) + /// before this item is built — never trusted from the wire again here. + center: DistrictPos, + n: u32, + }, } impl GenWorkItem { pub fn body_id(&self) -> Option<&str> { - if let GenWorkItem::AnalyzeBody { body_id, .. } = self { - Some(body_id) + match self { + GenWorkItem::AnalyzeBody { body_id, .. } => Some(body_id), + _ => None, + } + } + + /// Coalescing key for `DeriveWindow` items only — `(connection, body)`. + /// `None` for every other variant (they don't coalesce this way). + pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str)> { + if let GenWorkItem::DeriveWindow { + body_id, conn_id, .. + } = self + { + Some((*conn_id, body_id)) } else { None } @@ -187,6 +249,19 @@ pub enum GenCompletion { /// Boxed to keep `GenCompletion` variant sizes balanced. filled: Box, }, + /// A district window finished deriving (D-226 T-1124 amendment, T-1137). + /// The main thread inserts `layer` into the `DistrictWindowCache` keyed by + /// `(body_id, layer.center, layer.n)` — NOT pushed directly into any + /// in-flight response (the window's requester re-polls per the existing + /// D-225 loop and hits the now-populated cache on its next request; see + /// `handle_atlas_request`'s window branch). + WindowDerived { + body_id: String, + /// Boxed to keep `GenCompletion` variant sizes balanced (six + /// `Vec`s — comparable to `SkeletonGenerated`/`ChunkFilled`'s own + /// boxing rationale). + layer: Box, + }, /// Work item failed — body_id or city_id for logging. Failed { item: GenWorkItem, reason: String }, } @@ -303,6 +378,43 @@ impl GenerationQueue { self.dispatch_next(); } + /// Submit a `DeriveWindow` item with per-connection coalescing (D-226 + /// T-1124 amendment §1, "recommended"): if a `DeriveWindow` item for the + /// SAME `(connection, body)` is still sitting in the pending queue + /// (not yet dispatched to a Rayon worker), it is replaced in place by the + /// new one — a pan-burst that queues several window requests for the same + /// connection+body before the first is dispatched collapses to one derive. + /// + /// Deliberately does **not** attempt to cancel an item already dispatched + /// to a Rayon worker (no cancellation channel exists, and the amendment + /// does not require it — bounding queue buildup is the goal, not + /// interrupting in-flight compute). `item` MUST be a `DeriveWindow` + /// variant; any other variant is submitted via the plain `submit` path + /// with no coalescing (this method still accepts it for caller + /// convenience, but the supersede check is a no-op when + /// `window_supersede_key()` returns `None`). + pub fn submit_window(&self, item: GenWorkItem, priority: GenPriority) { + if let Some(key) = item.window_supersede_key() { + let key = (key.0, key.1.to_string()); + let mut pending = self.pending.lock().unwrap(); + pending.retain(|q| { + q.item + .window_supersede_key() + .map(|k| (k.0, k.1.to_string()) != key) + .unwrap_or(true) + }); + let pos = pending + .iter() + .position(|q| q.priority > priority) + .unwrap_or(pending.len()); + pending.insert(pos, QueuedWork { priority, item }); + drop(pending); + self.dispatch_next(); + } else { + self.submit(item, priority); + } + } + /// Drain all completed items from the channel and dispatch pending work. /// /// Call once per tick from the main thread. Returns all completions @@ -491,6 +603,52 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { filled: Box::new(filled), } } + GenWorkItem::DeriveWindow { + body_id, + conn_id: _, // routing-only (queue-level coalescing); the derive itself is connection-agnostic + heightmap_path, + sea_level, + body_seed, + body_params, + center, + n, + } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { + Ok(hm) => { + // Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the + // window derive must run on the SAME working-grid resolution the + // whole-body cascade uses, or district positions between the two + // views would disagree (derive_district maps DistrictPos through + // ta.w/ta.h, T-1137 decision note). + let working = if hm.width > GRID_W || hm.height > GRID_H { + hm.downsample(GRID_W, GRID_H) + } else { + hm + }; + // Re-derive TerrainAnalysis via run_layer1 (T-1137 binding + // decision — see the DeriveWindow variant doc for the numbers). + // This is the SAME workaround aliveness_probe --render already + // uses when CascadeSnapshot.terrain_analysis is None. + let (_, ta) = crate::atlas::layer1::run_layer1(&working); + let climate = ClimateConstants::default(); + let layer = build_district_window_layer( + *body_seed, + body_id, + body_params, + &ta, + *center, + *n, + &climate, + ); + GenCompletion::WindowDerived { + body_id: body_id.clone(), + layer: Box::new(layer), + } + } + Err(e) => GenCompletion::Failed { + item: item.clone(), + reason: format!("heightmap load failed: {e}"), + }, + }, } } @@ -814,4 +972,145 @@ mod tests { "a chunk containing a building must derive shell voxels" ); } + + // ------------------------------------------------------------------- + // DeriveWindow / submit_window coalescing (D-226 T-1124 amendment, T-1137) + // ------------------------------------------------------------------- + + /// Build a `DeriveWindow` work item pointing at a tiny test heightmap, + /// mirroring `analyze()`'s fixture shape. + fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem { + GenWorkItem::DeriveWindow { + body_id: body_id.to_string(), + conn_id, + heightmap_path: test_heightmap_path(), + sea_level: 0.3, + body_seed: SeedChain::for_body(42, body_id), + body_params: Box::new(BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }), + center, + n: 4, + } + } + + /// The full `DeriveWindow` → Rayon → `WindowDerived` round trip: the + /// real re-derive-via-`run_layer1` path (T-1137 binding decision) runs + /// end to end and produces a populated `DistrictWindowLayer`. + #[test] + fn derive_window_round_trip_produces_populated_layer() { + let q = make_queue(); + q.submit( + derive_window("TestBody", ConnectionId(0), (2, -1)), + GenPriority::Immediate, + ); + std::thread::sleep(Duration::from_millis(150)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 1); + let GenCompletion::WindowDerived { body_id, layer } = &completions[0] else { + panic!("expected WindowDerived, got {:?}", completions[0]); + }; + assert_eq!(body_id, "TestBody"); + assert_eq!(layer.center, (2, -1)); + assert_eq!(layer.n, 4); + assert_eq!(layer.morphology.len(), 16); + assert_eq!(layer.elev_q.len(), 16); + assert_eq!(layer.temp_dc.len(), 16); + assert_eq!(layer.moisture_q.len(), 16); + assert_eq!(layer.vegetation.len(), 16); + assert_eq!(layer.glaciation.len(), 16); + } + + /// `submit_window` coalescing (D-226 T-1124 amendment §1 "recommended"): + /// two `DeriveWindow` items for the SAME `(connection, body)` queued + /// while the pool is saturated collapse to ONE pending entry — the + /// second submission replaces the first rather than queuing alongside it. + #[test] + fn submit_window_coalesces_same_connection_and_body() { + // Single-thread pool: the first item occupies the only worker, so + // subsequent DeriveWindow submissions stay in `pending` long enough + // to inspect (mirrors `priority_ordering_respected_under_saturation`'s + // saturation trick). Uses `analyze()` (real cascade work — measurable + // latency), NOT `FillChunk` (documented "trivially fast" — it can + // complete before the next `submit_window` call even runs, which + // would make `pending_count()` observe 0 instead of 1, a real race + // this test hit before switching occupiers). + let q = GenerationQueue::with_threads(1); + q.submit(analyze("Occupier"), GenPriority::Low); + + let conn = ConnectionId(7); + q.submit_window(derive_window("Coal", conn, (0, 0)), GenPriority::Immediate); + assert_eq!( + q.pending_count(), + 1, + "one DeriveWindow queued behind the saturating item" + ); + + // A second DeriveWindow for the SAME (connection, body) supersedes + // the first — pending count stays at 1, not 2. + q.submit_window(derive_window("Coal", conn, (5, 5)), GenPriority::Immediate); + assert_eq!( + q.pending_count(), + 1, + "same (connection, body) DeriveWindow must supersede, not queue alongside" + ); + + // Drain everything and confirm exactly one WindowDerived for "Coal", + // carrying the SECOND (superseding) center — not the first. + std::thread::sleep(Duration::from_millis(150)); + let mut completions = q.drain_completions(); + std::thread::sleep(Duration::from_millis(150)); + completions.extend(q.drain_completions()); + + let window_completions: Vec<_> = completions + .iter() + .filter_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "Coal" { + return Some(layer); + } + } + None + }) + .collect(); + assert_eq!( + window_completions.len(), + 1, + "exactly one WindowDerived for the coalesced body, not two" + ); + assert_eq!( + window_completions[0].center, + (5, 5), + "the surviving item must be the SECOND (superseding) submission" + ); + } + + /// `submit_window` does NOT coalesce across different connections or + /// different bodies — only an exact `(connection, body)` match supersedes. + #[test] + fn submit_window_does_not_coalesce_different_keys() { + let q = GenerationQueue::with_threads(1); + // See `submit_window_coalesces_same_connection_and_body`'s comment on + // why the occupier must be `analyze()`, not `FillChunk`. + q.submit(analyze("Occupier2"), GenPriority::Low); + + // Different connections, same body — must NOT coalesce. + q.submit_window( + derive_window("Shared", ConnectionId(1), (0, 0)), + GenPriority::Immediate, + ); + q.submit_window( + derive_window("Shared", ConnectionId(2), (1, 1)), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 2, + "different connections requesting the same body must NOT coalesce" + ); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 399a95a7e..8d1ae01d1 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -11,16 +11,19 @@ //! 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 bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::body_params_reader::BodyParamsReader; use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; +use crate::atlas::district_profile::DistrictPos; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; +use crate::bridge::ConnectionId; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAuthority, ZoningType}; @@ -28,7 +31,15 @@ use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAutho /// (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). +/// Hard server-side clamp on [`AtlasLayerRequest::window_n`] (D-226 T-1124 +/// amendment §4, binding numbers). 64×64 districts ≈ 131 km per side — the +/// same window size `aliveness_probe --render`'s default already proved out +/// server-side (T-1123). **Never trust `window_n` from the wire** — every +/// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving. +pub const DISTRICT_WINDOW_MAX_N: u32 = 64; + +/// A client request for a body's generation layers (D-225), extended with an +/// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137). /// /// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item` /// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::Region` @@ -39,6 +50,16 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; pub struct AtlasLayerRequest { pub body_id: String, pub up_to: CascadeLayer, + /// District-window centre (D-226 T-1124 amendment §1, T-1137). `None` = no + /// window requested (whole-body layers only — today's behavior, byte-unchanged + /// for every existing caller thanks to `#[serde(default)]`). + #[serde(default)] + pub window_center: Option, + /// Window side length in districts. Ignored when `window_center` is `None`. + /// Clamped server-side to `[1, DISTRICT_WINDOW_MAX_N]` — **never trusted + /// from the wire** (D-226 T-1124 amendment §4). + #[serde(default)] + pub window_n: u32, } /// Status of a layer response (D-225). @@ -72,7 +93,8 @@ pub struct DistrictGridLayer { /// A layer response: the computed `Layer1Output` + the coarse district grid /// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) + /// the region climate grid (T-1113) + the quarter-footprint overlay (T-1112, -/// T-1119), or a non-ready status. +/// T-1119) + the district-resolution window query (T-1124, T-1137), or a +/// non-ready status. /// /// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer /// pattern tops out at six fields for the **dense whole-body layer family** @@ -87,15 +109,14 @@ pub struct DistrictGridLayer { /// The D-226 T-1124 amendment (2026-07-18) RESOLVED what carries the next /// addition, and it is NOT this family: a **windowed viewport query** is a /// categorically different payload (keyed on the *request* `(body, center, n)`, -/// re-fetched per pan, not a per-body snapshot). T-1124 specifies a -/// `district_window: Option` field (wiring is a follow-up -/// ticket, T-1137 — not yet added here) that rides on `AtlasLayerResponse` but -/// is explicitly OUTSIDE the whole-body family and does not count against this -/// six-field ceiling (D-226 T-1124 §2). The windowed family has its own hard -/// cap: exactly ONE windowed-query field; a second windowed query (a second -/// viewport, a windowed chunk-preview) is a dedicated response message by rule, -/// not a second `Option` here (D-226 T-1124 §2, symmetric with the -/// request-side five-shape demux ceiling in `bridge/mod.rs`). +/// re-fetched per pan, not a per-body snapshot). `district_window` (wired here, +/// T-1137) rides on `AtlasLayerResponse` but is explicitly OUTSIDE the +/// whole-body family and does not count against the six-field ceiling above +/// (D-226 T-1124 §2). The windowed family has its own hard cap: exactly ONE +/// windowed-query field; a second windowed query (a second viewport, a +/// windowed chunk-preview) is a dedicated response message by rule, not a +/// second `Option` here (D-226 T-1124 §2, symmetric with the request-side +/// five-shape demux ceiling in `bridge/mod.rs`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, @@ -116,6 +137,12 @@ pub struct AtlasLayerResponse { /// The region climate grid for the Atlas overlay (D-243 §3, T-1113). /// `Some` on a cache hit once the Region layer has run; `None` otherwise. pub region_grid: Option, + /// The requested district window (D-226 T-1124 amendment, T-1137), or + /// `None` when the request carried no `window_center` / no window data is + /// cached yet for a pending derive. Distinct from the five layers above: + /// keyed on the REQUEST `(body, center, n)`, not on the body alone — see + /// the struct-level doc. + pub district_window: Option, /// The quarter-footprint overlay (D-226 T-1112 amendment, T-1119). `Some` /// on a cache hit once at least one settlement's quarter skeleton has been /// generated (`state.quarters` non-empty); `None` otherwise, including a @@ -235,6 +262,180 @@ pub fn build_region_grid( }) } +// --------------------------------------------------------------------------- +// DistrictWindowLayer (D-226 T-1124 amendment, T-1137) +// --------------------------------------------------------------------------- + +/// The requested district window: an `n × n` grid of TRUE 2 km districts +/// centred on `center`, derived on-demand via `district_profile::derive_district` +/// (D-226 T-1124 amendment §2). **Echoes `center`/`n` back** — this is the +/// client's race-condition guard, not a convenience field: because +/// `derive_district` is pure and deterministic (D-227), the same `(center, n)` +/// query always yields the same payload, so the echoed tuple *is* the +/// cache/staleness key the client compares against its most recently requested +/// window (`body_id` disambiguation rides the enclosing `AtlasLayerResponse`, +/// not the echo — see the amendment). +/// +/// All six arrays are dense row-major `n × n` (`i = row * n + col`), matching +/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell wire +/// cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead (D-226 +/// T-1124 amendment §4). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DistrictWindowLayer { + pub center: DistrictPos, + pub n: u32, + /// `MorphologyZone` discriminant, the frozen 17-zone vocabulary (D-239 §6). + pub morphology: Vec, + /// 0-100, matches `DistrictGridLayer.elev_q` encoding. + pub elev_q: Vec, + /// Deci-°C, [`REGION_TEMP_NONE_DC`] sentinel — the SAME scheme as + /// `RegionGridLayer.mean_temp_dc`, deliberately not a separate + /// district-tier quantization (one temperature colorizer spans both zoom + /// levels, D-226 T-1124 amendment §2). + pub temp_dc: Vec, + /// 0-100, matches `DistrictGridLayer` precedent. + pub moisture_q: Vec, + /// `VegetationClass` discriminant, 0-6 including `Marine = 6` (T-1126) — + /// any client palette MUST be exhaustive over `Marine` (D-226 T-1124 + /// amendment §3, non-negotiable — the ocean-blind-vegetation bug this + /// field caught at the district tier). + pub vegetation: Vec, + /// `GlaciationGrade` discriminant, 0-4 (T-1127). + pub glaciation: Vec, +} + +/// Key for the server-side window derive cache (T-1137): `(body_id, center, n)`. +/// D-227 purity means a cached window is valid forever for a given body+seed — +/// no staleness/TTL invalidation is needed, only a bound on unbounded growth +/// (see [`DistrictWindowCache`]). +pub type DistrictWindowKey = (String, DistrictPos, u32); + +/// Bounded LRU-ish cache of completed district-window derives (T-1137), a +/// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are +/// keyed on the *request* `(body, center, n)`, not the body alone (see the +/// struct-level doc on [`AtlasLayerResponse`]), so they don't fit the +/// per-body cache's keying at all. Eviction is capacity-only FIFO-by-insertion +/// (not access-recency LRU like `BodyWorldStateCache`) — window requests are +/// comparatively rare and cheap to re-derive on a genuine miss (a background +/// re-submit, never a stall), so exact recency tracking isn't worth the +/// bookkeeping; a simple bound against unbounded growth is enough. +#[derive(Resource, Debug, Default)] +pub struct DistrictWindowCache { + entries: std::collections::BTreeMap, + /// Insertion order, oldest first — the eviction queue. + order: std::collections::VecDeque, + capacity: usize, +} + +/// Default capacity for [`DistrictWindowCache`] — generous relative to +/// `BodyWorldStateCache::CACHE_CAPACITY` (50 bodies) since each entry here is +/// far smaller (a handful of `Vec`/`Vec` at `n ≤ 64`, ≤ 28 KiB raw vs. +/// `BodyWorldState`'s full heightmap + districts + regions), and several +/// windows can legitimately be live per body (a player panning around). +pub const DISTRICT_WINDOW_CACHE_CAPACITY: usize = 256; + +impl DistrictWindowCache { + pub fn new(capacity: usize) -> Self { + Self { + entries: std::collections::BTreeMap::new(), + order: std::collections::VecDeque::new(), + capacity, + } + } + + /// Look up a cached window by its full key. Never mutates — window + /// validity has no time component (D-227), so there is nothing to bump. + pub fn get(&self, key: &DistrictWindowKey) -> Option<&DistrictWindowLayer> { + self.entries.get(key) + } + + /// Insert a completed window derive, evicting the oldest entry first if + /// at capacity. Re-inserting an existing key replaces the value without + /// moving it in the eviction order (D-227: the value can only ever be + /// identical, so this is a no-op in practice, but stays correct either way). + pub fn insert(&mut self, key: DistrictWindowKey, layer: DistrictWindowLayer) { + if !self.entries.contains_key(&key) { + if self.entries.len() >= self.capacity { + if let Some(victim) = self.order.pop_front() { + self.entries.remove(&victim); + } + } + self.order.push_back(key.clone()); + } + self.entries.insert(key, layer); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Build a [`DistrictWindowLayer`] by deriving every district in the +/// `n × n` window around `center` (T-1137). Mirrors +/// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe +/// this design promotes to a served layer, D-226 T-1124 amendment §2) — same +/// row-major indexing, same `derive_district` call per cell. +/// +/// `n` MUST already be clamped to `[1, DISTRICT_WINDOW_MAX_N]` by the caller — +/// this function trusts it verbatim (the clamp is `handle_atlas_request`'s +/// job, applied once at the wire boundary, not re-checked on every internal +/// caller per the existing codebase convention of clamping at the edge). +pub fn build_district_window_layer( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + center: DistrictPos, + n: u32, + climate: &crate::atlas::district_profile::ClimateConstants, +) -> DistrictWindowLayer { + let n_i = n as i32; + let half = n_i / 2; + let cells = (n * n) as usize; + let mut morphology = vec![0u8; cells]; + let mut elev_q = vec![0u8; cells]; + let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; + let mut moisture_q = vec![0u8; cells]; + let mut vegetation = vec![0u8; cells]; + let mut glaciation = vec![0u8; cells]; + for row in 0..n_i { + for col in 0..n_i { + // Row 0 = northmost, matching aliveness_probe's render_window_panels + // (derive_district maps negative wy to negative lat_frac = north). + let dp = (center.0 - half + col, center.1 - half + row); + let prof = crate::atlas::district_profile::derive_district( + seed, body_id, params, ta, dp, climate, + ); + let i = (row * n_i + col) as usize; + morphology[i] = prof.morphology_zone as u8; + elev_q[i] = prof.elev_q.clamp(0, 100) as u8; + temp_dc[i] = match prof.temperature_c { + Some(t) => { + ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 + } + None => REGION_TEMP_NONE_DC, + }; + moisture_q[i] = prof.moisture_q.clamp(0, 100) as u8; + vegetation[i] = prof.vegetation_class as u8; + glaciation[i] = prof.glaciation_grade as u8; + } + } + DistrictWindowLayer { + center, + n, + morphology, + elev_q, + temp_dc, + moisture_q, + vegetation, + glaciation, + } +} + // --------------------------------------------------------------------------- // QuarterFootprintLayer (D-226 T-1112 amendment, T-1119) // --------------------------------------------------------------------------- @@ -544,6 +745,97 @@ pub fn build_settlement_layer(state: &BodyWorldState) -> Option Some(SettlementLayer { settlements }) } +/// Resolve `req`'s district-window query, if any (D-226 T-1124 amendment, +/// T-1137). Returns `None` immediately when `req.window_center` is absent (no +/// window requested — the common case, zero cost). +/// +/// **Independent of the whole-body cache state** (the amendment is explicit: +/// "the window derivation depends only on `TerrainAnalysis` + `BodyParams` +/// being resolvable for the body ... not on which whole-body layers the +/// cascade has cached") — so this runs whether `handle_atlas_request` is +/// about to take its cache-hit or cache-miss branch, sharing neither's control +/// flow. +/// +/// Cache hit (`(body_id, center, n)` already in `window_cache`) → `Some` +/// immediately, no queue submission (D-227: a previously-derived window for +/// this body+seed is valid forever, no staleness check needed). Cache miss → +/// submit a `DeriveWindow` work item (queue-based, per the amendment's binding +/// serving model — never inline here) and return `None`; the *next* request +/// for this `(body, center, n)` re-checks the cache and finds it populated +/// once `drain_generation_completions` has processed the completion (the +/// existing D-225 poll-and-recheck-cache pattern every other layer already +/// uses, not a push). +/// +/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` here — the ONE place +/// that clamp is applied; nothing downstream re-checks the wire value. +#[allow(clippy::too_many_arguments)] +fn serve_district_window( + req: &AtlasLayerRequest, + window_cache: &mut DistrictWindowCache, + queue: &GenerationQueue, + resolver: &BodySourceResolver, + body_params_reader: Option<&BodyParamsReader>, + world_seed: u64, + conn_id: ConnectionId, +) -> Option { + let center = req.window_center?; + let n = req.window_n.clamp(1, DISTRICT_WINDOW_MAX_N); + + let key: DistrictWindowKey = (req.body_id.clone(), center, n); + if let Some(layer) = window_cache.get(&key) { + return Some(layer.clone()); + } + + // Miss — resolve heightmap + body params and submit a background derive. + // Read/resolve failures are non-fatal for the window (log + skip): the + // window simply stays None on this response, same as an unrun whole-body + // layer, rather than failing the entire AtlasLayerResponse. + let heightmap_path = match resolver.resolve(&req.body_id) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + body_id = %req.body_id, + error = %e, + "district window request: heightmap resolve failed — window stays None" + ); + return None; + } + }; + let Some(reader) = body_params_reader else { + tracing::warn!( + body_id = %req.body_id, + "district window request: no body_params_reader wired — window stays None" + ); + return None; + }; + let body_params = match reader.read_body_params(&req.body_id) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + body_id = %req.body_id, + error = %e, + "district window request: body_params read failed — window stays None" + ); + return None; + } + }; + + queue.submit_window( + GenWorkItem::DeriveWindow { + body_id: req.body_id.clone(), + conn_id, + heightmap_path, + sea_level: DEFAULT_SEA_LEVEL, + body_seed: SeedChain::for_body(world_seed, &req.body_id), + body_params: Box::new(body_params), + center, + n, + }, + GenPriority::Immediate, + ); + None +} + /// 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. /// @@ -557,16 +849,35 @@ pub fn build_settlement_layer(state: &BodyWorldState) -> Option /// 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. +/// +/// `window_cache` + `conn_id` serve the optional district-window query +/// (D-226 T-1124 amendment, T-1137) via [`serve_district_window`] — see that +/// function for the caching/coalescing model. `conn_id` is used ONLY as the +/// window request's coalescing key; nothing else in this function is +/// connection-aware (the D-254 §2 convention this proxy already follows). +#[allow(clippy::too_many_arguments)] pub fn handle_atlas_request( req: &AtlasLayerRequest, cache: &mut BodyWorldStateCache, + window_cache: &mut DistrictWindowCache, queue: &GenerationQueue, resolver: &BodySourceResolver, city_reader: Option<&CityContextReader>, body_params_reader: Option<&BodyParamsReader>, world_seed: u64, current_tick: SimTick, + conn_id: ConnectionId, ) -> AtlasLayerResponse { + let district_window = serve_district_window( + req, + window_cache, + queue, + resolver, + body_params_reader, + world_seed, + conn_id, + ); + // Cache hit — serve immediately. if let Some(state) = cache.get(&req.body_id, current_tick) { let layer1 = Layer1Output { @@ -599,6 +910,7 @@ pub fn handle_atlas_request( road_graph, settlements, region_grid, + district_window, quarter_footprints, }; } @@ -673,6 +985,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, } } @@ -686,6 +999,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, }, Err(e) => AtlasLayerResponse { @@ -696,6 +1010,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + district_window, quarter_footprints: None, }, } @@ -829,6 +1144,340 @@ mod tests { assert_eq!(grid.moisture_q[1], 5); } + // ----------------------------------------------------------------------- + // DistrictWindowLayer (D-226 T-1124 amendment, T-1137) + // ----------------------------------------------------------------------- + + /// Minimal deterministic heightmap fixture, mirroring + /// `district_profile::tests::test_hm` (T-1137: the window path shares the + /// same on-demand `derive_district` call, so it earns the same fixture + /// shape). + fn window_test_hm() -> crate::atlas::heightmap::BodyHeightmap { + use crate::atlas::heightmap::BodyHeightmap; + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + } + } + + fn window_test_ta( + hm: &crate::atlas::heightmap::BodyHeightmap, + ) -> crate::atlas::features::TerrainAnalysis { + use crate::atlas::drainage; + use crate::atlas::features::TerrainAnalysis; + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + fn window_test_params() -> crate::atlas::district_profile::BodyParams { + crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } + } + + /// `build_district_window_layer` produces a dense `n × n` row-major grid + /// (the `DistrictGridLayer`/`RegionGridLayer` indexing convention) whose + /// cell count and per-array lengths match `n`, and whose values are + /// pulled straight from the corresponding `derive_district` profile field + /// (T-1137). + #[test] + fn build_district_window_layer_produces_dense_n_by_n_grid() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + + let n = 4u32; + let layer = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (10, -5), n, &climate); + + assert_eq!(layer.center, (10, -5)); + assert_eq!(layer.n, n); + let cells = (n * n) as usize; + assert_eq!(layer.morphology.len(), cells); + assert_eq!(layer.elev_q.len(), cells); + assert_eq!(layer.temp_dc.len(), cells); + assert_eq!(layer.moisture_q.len(), cells); + assert_eq!(layer.vegetation.len(), cells); + assert_eq!(layer.glaciation.len(), cells); + + // Spot-check one cell against a direct derive_district call — the + // window builder must not transform the profile's values, only pack + // them (row 0, col 0 → district (center.0 - n/2, center.1 - n/2)). + let half = (n / 2) as i32; + let dp = (10 - half, -5 - half); + let prof = crate::atlas::district_profile::derive_district( + seed, + "test_body", + ¶ms, + &ta, + dp, + &climate, + ); + assert_eq!(layer.morphology[0], prof.morphology_zone as u8); + assert_eq!(layer.elev_q[0], prof.elev_q.clamp(0, 100) as u8); + assert_eq!(layer.moisture_q[0], prof.moisture_q.clamp(0, 100) as u8); + assert_eq!(layer.vegetation[0], prof.vegetation_class as u8); + assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8); + } + + /// Clamped-window edge: `n = 1` is the minimum valid window (a single + /// district) — no panic, no empty output, exactly one cell per array. + #[test] + fn build_district_window_layer_handles_n_equals_one() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + + let layer = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (0, 0), 1, &climate); + assert_eq!(layer.n, 1); + assert_eq!(layer.morphology.len(), 1); + assert_eq!(layer.elev_q.len(), 1); + assert_eq!(layer.temp_dc.len(), 1); + assert_eq!(layer.moisture_q.len(), 1); + assert_eq!(layer.vegetation.len(), 1); + assert_eq!(layer.glaciation.len(), 1); + } + + /// Determinism spot-check (D-010, T-1123 precedent promoted to a real + /// test per the ticket): two full derive passes over the SAME window are + /// byte-identical, at a window size large enough to exercise many cells + /// (mirrors `aliveness_probe --render`'s own two-pass proof, now pinned + /// as a unit test rather than a probe-only demonstration). + #[test] + fn build_district_window_layer_two_passes_are_byte_identical() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); + + let n = 8u32; + let first = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + let second = + build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + assert_eq!( + first, second, + "two full derive passes over the same (center, n) must be byte-identical (D-010/D-227)" + ); + } + + /// [`DistrictWindowCache`] insert/get round-trips, and a capacity-1 cache + /// evicts the oldest entry FIFO — mirroring `BodyWorldStateCache`'s own + /// `evicts_lru_on_overflow` precedent, adapted to this cache's + /// capacity-only insertion-order eviction (no access-recency tracking, + /// per the struct doc: D-227 means a cached window has no staleness to + /// track, only unbounded growth to bound). + #[test] + fn district_window_cache_insert_get_and_evict() { + let mut cache = DistrictWindowCache::new(2); + let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4); + let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4); + let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4); + let mk = |center, n| DistrictWindowLayer { + center, + n, + morphology: vec![0; (n * n) as usize], + elev_q: vec![0; (n * n) as usize], + temp_dc: vec![REGION_TEMP_NONE_DC; (n * n) as usize], + moisture_q: vec![0; (n * n) as usize], + vegetation: vec![0; (n * n) as usize], + glaciation: vec![0; (n * n) as usize], + }; + + assert!(cache.get(&key_a).is_none()); + cache.insert(key_a.clone(), mk((0, 0), 4)); + cache.insert(key_b.clone(), mk((1, 1), 4)); + assert_eq!(cache.len(), 2); + assert!(cache.get(&key_a).is_some()); + assert!(cache.get(&key_b).is_some()); + + // Cache at capacity (2): inserting a third entry evicts key_a (oldest). + cache.insert(key_c.clone(), mk((2, 2), 4)); + assert_eq!(cache.len(), 2); + assert!( + cache.get(&key_a).is_none(), + "key_a should have been evicted" + ); + assert!(cache.get(&key_b).is_some()); + assert!(cache.get(&key_c).is_some()); + } + + /// `handle_atlas_request`'s window branch clamps `window_n` server-side to + /// `[1, DISTRICT_WINDOW_MAX_N]` — a request claiming an oversized `n` on + /// the wire never reaches `build_district_window_layer` un-clamped. This + /// exercises the full request→submit→drain→cache→re-request loop with an + /// out-of-range `window_n`, confirming the CACHED layer (once the + /// background derive completes) carries the CLAMPED `n`, not the + /// requested one. + #[test] + fn handle_atlas_request_clamps_oversized_window_n() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let queue = GenerationQueue::with_threads(1); + + let oversized_req = AtlasLayerRequest { + body_id: "GJ1c".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((0, 0)), + window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust + }; + + let resp = handle_atlas_request( + &oversized_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + // First request: window not yet cached → None, but a DeriveWindow + // must have been submitted (checked via the drain below). + assert!(resp.district_window.is_none()); + + // Wait for the Rayon DeriveWindow work item to complete. + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let window_completion = completions.into_iter().find_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "GJ1c" { + return Some(layer); + } + } + None + }); + let layer = window_completion.expect("DeriveWindow must complete for GJ1c"); + assert_eq!( + layer.n, DISTRICT_WINDOW_MAX_N, + "server must clamp window_n to DISTRICT_WINDOW_MAX_N, never trust the wire value" + ); + } + + /// `serve_district_window` returns `None` (no window requested) when the + /// request carries no `window_center` — the common case, and the ONLY + /// path every pre-T-1137 caller takes (wire back-compat: an old client's + /// `{body_id, up_to}` frame decodes with `window_center: None` via + /// `#[serde(default)]`). + #[test] + fn handle_atlas_request_no_window_center_leaves_district_window_none() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver) = empty_resolver(); + let queue = GenerationQueue::with_threads(1); + + let resp = handle_atlas_request( + &req("GJ1c"), // window_center: None + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + None, + 42, + 1, + test_conn_id(), + ); + assert!(resp.district_window.is_none()); + assert!( + window_cache.is_empty(), + "no window requested → no DeriveWindow submitted, cache stays empty" + ); + } + + /// `AtlasLayerResponse.district_window` survives a MessagePack round trip + /// (mirrors the existing `atlas_layer_response_with_new_layers_round_trips_msgpack` + /// precedent) — the wire shape every field the amendment specifies: + /// echoed `center`/`n`, all six parallel arrays including the + /// `REGION_TEMP_NONE_DC` sentinel and `VegetationClass::Marine = 6`. + #[test] + fn district_window_layer_round_trips_msgpack_inside_response() { + let window = DistrictWindowLayer { + center: (10, -5), + n: 2, + morphology: vec![0, 8, 14, 16], + elev_q: vec![0, 45, 98, 60], + temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], + moisture_q: vec![90, 55, 0, 100], + vegetation: vec![6, 3, 0, 5], // includes Marine = 6 + glaciation: vec![0, 0, 4, 1], + }; + let resp = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: None, + district_grid: None, + road_graph: None, + settlements: None, + region_grid: None, + district_window: Some(window.clone()), + quarter_footprints: None, + }; + + let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); + let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode"); + + let dw = decoded + .district_window + .expect("district_window survives round trip"); + assert_eq!(dw, window); + assert_eq!(dw.center, (10, -5)); + assert_eq!(dw.n, 2); + assert_eq!( + dw.temp_dc[2], REGION_TEMP_NONE_DC, + "airless sentinel preserved" + ); + assert_eq!(dw.vegetation[0], 6, "Marine discriminant preserved"); + } + + /// Wire back-compat (D-226 T-1124 amendment §1): a pre-T-1137 request + /// frame carrying only `{body_id, up_to}` — no `window_center`/`window_n` + /// keys at all — decodes cleanly via `#[serde(default)]`, byte-unchanged + /// for every existing caller. + #[test] + fn old_request_frame_without_window_fields_decodes_with_none() { + #[derive(serde::Serialize)] + struct OldAtlasLayerRequest { + body_id: String, + up_to: CascadeLayer, + } + let old = OldAtlasLayerRequest { + body_id: "GJ1c".into(), + up_to: CascadeLayer::Topography, + }; + let bytes = rmp_serde::to_vec_named(&old).expect("encode old-shape frame"); + let decoded: AtlasLayerRequest = rmp_serde::from_slice(&bytes).expect("decode"); + assert_eq!(decoded.body_id, "GJ1c"); + assert_eq!(decoded.up_to, CascadeLayer::Topography); + assert_eq!(decoded.window_center, None); + assert_eq!(decoded.window_n, 0); + } + /// T-1119: `build_quarter_footprint_layer` returns `None` when no /// settlement's quarter skeleton has been generated (`state.quarters` /// empty), mirroring `build_district_grid`/`build_region_grid`'s @@ -1403,6 +2052,7 @@ mod tests { road_graph: build_road_graph_layer(&state), settlements: build_settlement_layer(&state), region_grid: build_region_grid(&state), + district_window: None, quarter_footprints: build_quarter_footprint_layer(&state, world_seed), }; @@ -1439,9 +2089,15 @@ mod tests { AtlasLayerRequest { body_id: body_id.to_string(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, } } + fn test_conn_id() -> ConnectionId { + ConnectionId(1) + } + 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 @@ -1517,16 +2173,19 @@ mod tests { }); let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Ready); assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c"); @@ -1537,16 +2196,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = resolver_with_body("GJ1c"); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); assert!(resp.layer1.is_none()); @@ -1567,16 +2229,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = empty_resolver(); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("ghost"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::NotFound); } @@ -1651,16 +2316,19 @@ mod tests { 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 mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, Some(¶ms_reader), 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); @@ -1694,12 +2362,14 @@ mod tests { let ready = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, Some(¶ms_reader), 42, 2, + test_conn_id(), ); assert_eq!(ready.status, AtlasLayerStatus::Ready); assert!( @@ -1714,16 +2384,19 @@ mod tests { let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); let (_db, resolver) = resolver_with_body("GJ1c"); let queue = GenerationQueue::with_threads(1); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); let resp = handle_atlas_request( &req("GJ1c"), &mut cache, + &mut window_cache, &queue, &resolver, None, None, // no body_params_reader 42, 1, + test_conn_id(), ); assert_eq!(resp.status, AtlasLayerStatus::Pending); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 84b66f109..a24ea3a3d 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -27,7 +27,10 @@ use crate::atlas::city_context_reader::{ use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; -use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus}; +use crate::atlas::layer_proxy::{ + handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache, + DISTRICT_WINDOW_CACHE_CAPACITY, +}; use crate::atlas::road_graph::{RoadGraph, RoadNode}; use crate::atlas::scale; use crate::atlas::skeleton_gen::derive_complexity; @@ -61,6 +64,7 @@ impl Plugin for GenerationPlugin { fn build(&self, app: &mut App) { app.insert_resource(GenerationQueue::new()) .insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)) + .insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)) .add_systems( Update, drain_generation_completions.in_set(TickPhase::PreInput), @@ -78,10 +82,18 @@ impl Plugin for GenerationPlugin { /// Drain inbound atlas layer requests and serve each through the proxy (#969, /// D-225): cache hit → Ready, miss → resolve + enqueue + Pending. Responses are /// buffered for the bridge to flush in `PostSnapshot`. +/// +/// `window_cache` serves the optional district-window query (D-226 T-1124 +/// amendment, T-1137) — see `handle_atlas_request`/`serve_district_window`. +/// Unlike the rest of `handle_atlas_request`, the window path IS +/// connection-aware (its coalescing key), so `conn_id` — already threaded +/// through this loop for response routing (D-254 §2) — is passed one level +/// further in for that one purpose only. fn serve_atlas_requests( mut requests: ResMut, mut responses: ResMut, mut cache: ResMut, + mut window_cache: ResMut, queue: Res, resolver: Option>, city_reader: Option>, @@ -106,12 +118,14 @@ fn serve_atlas_requests( Some(r) => handle_atlas_request( &req, &mut cache, + &mut window_cache, &queue, &r.0, reader, params_reader, world_seed, tick, + conn_id, ), None => AtlasLayerResponse { body_id: req.body_id.clone(), @@ -121,6 +135,7 @@ fn serve_atlas_requests( road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }, }; @@ -209,6 +224,7 @@ pub fn serve_browse_requests( fn drain_generation_completions( queue: Res, mut cache: ResMut, + mut window_cache: ResMut, city_reader: Option>, trait_catalog: Option>, rng: Option>, @@ -390,6 +406,15 @@ fn drain_generation_completions( "FillChunk derived (no Phase-5 consumer yet)" ); } + GenCompletion::WindowDerived { body_id, layer } => { + // D-226 T-1124 amendment, T-1137: cache the completed window — + // NOT pushed into any in-flight response (this drain has no + // notion of which connection(s) are waiting). The requester's + // NEXT poll (the existing D-225 re-request loop) hits + // `handle_atlas_request`'s window branch, which finds this + // entry via `DistrictWindowCache::get` and serves it. + window_cache.insert((body_id, layer.center, layer.n), *layer); + } } } } @@ -832,6 +857,7 @@ mod tests { let mut world = World::new(); world.insert_resource(GenerationQueue::new()); world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)); + world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)); world.resource::().submit( GenWorkItem::AnalyzeBody { @@ -876,10 +902,13 @@ mod tests { AtlasLayerRequest { body_id: "GJ1c".to_string(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }, )])); world.insert_resource(AtlasResponseBuffer::default()); world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY)); + world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY)); world.insert_resource(GenerationQueue::with_threads(1)); // No resolver / SimRng / SimulationTime — all optional in the system. diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index e9d276944..4ecd73c32 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1199,6 +1199,8 @@ mod inbound_tests { let req = AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }; let frame = rmp_serde::to_vec_named(&req).unwrap(); assert!( @@ -1242,6 +1244,8 @@ mod inbound_tests { let atlas_frame = rmp_serde::to_vec_named(&AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }) .unwrap(); let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap(); diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 53497669c..d54d769ce 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -369,6 +369,8 @@ fn single_tick_drains_all_ready_inbound_frames() { let req = AtlasLayerRequest { body_id: "GJ1c".into(), up_to: CascadeLayer::Topography, + window_center: None, + window_n: 0, }; let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize"); write_framed(&mut stream, &payload).expect("write atlas frame"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 601a33528..6eabe2667 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -4,9 +4,9 @@ use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork}; use settled_reach_server::atlas::layer1::Layer1Output; use settled_reach_server::atlas::layer_proxy::{ - AtlasLayerResponse, AtlasLayerStatus, QuarterFootprintEntry, QuarterFootprintLayer, - RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, - SettlementLayer, SettlementSizeClass, + AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry, + QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, + SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC, }; use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; @@ -676,6 +676,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: Some(road_graph), settlements: Some(settlements), region_grid: Some(region_grid), + district_window: None, quarter_footprints: Some(quarter_footprints), }; write_fixture( @@ -691,6 +692,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }; write_fixture( @@ -706,10 +708,43 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + district_window: None, quarter_footprints: None, }; write_fixture( "atlas_response_not_found", &rmp_serde::to_vec_named(¬_found).unwrap(), ); + + // D-226 T-1124 amendment, T-1137: a Ready response carrying a populated + // district_window — the windowed-family field, distinct from the five + // whole-body layers above. A small n=2 window keeps the fixture readable + // while exercising every field (including the REGION_TEMP_NONE_DC + // sentinel and VegetationClass::Marine = 6, both non-negotiable per the + // amendment §3). + let window = DistrictWindowLayer { + center: (10, -5), + n: 2, + morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland + elev_q: vec![0, 45, 98, 60], + temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], // 20.5°C, 15.0°C, airless sentinel, 8.0°C + moisture_q: vec![90, 55, 0, 100], + vegetation: vec![6, 3, 0, 5], // Marine, Forest, Absent, RiparianThicket + glaciation: vec![0, 0, 4, 1], // None, None, IceCap, Light + }; + let ready_with_window = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: None, + district_grid: None, + road_graph: None, + settlements: None, + region_grid: None, + district_window: Some(window), + quarter_footprints: None, + }; + write_fixture( + "atlas_response_ready_with_window", + &rmp_serde::to_vec_named(&ready_with_window).unwrap(), + ); }