feat(simulation): T-1137 windowed district layer — queue-served, coalesced, determinism-tested (D-226 T-1124 SS1-SS4)
AtlasLayerRequest gains window_center/window_n (serde-default, absent = whole-body, wire back-compat; demux untouched — up_to stays the discriminator). DistrictWindowLayer echoes center/n + six parallel arrays (morphology, elev_q, temp_dc i16 with the region sentinel, moisture_q, vegetation incl Marine=6, glaciation). Serving per the amendment's binding model: NEVER inline — GenWorkItem::DeriveWindow rides the Rayon queue, completion drain caches into DistrictWindowCache (bounded FIFO 256; no staleness by D-227 purity, capacity bound only), serve_district_window polls the cache and returns Pending-shaped None until derived. Per-connection coalescing: submit_window supersedes a still-pending item for the same (ConnectionId, body) — the surviving item is the newer one, proven by dedicated tests. TerrainAnalysis decision (option b, numbers in ticket/PR): re-derive via run_layer1 in the DeriveWindow branch rather than caching ~1.5MB x 50 LRU slots (~100MB permanent, the exact D-203 bloat T-1044's own text guarded against); ~45ms one-time on the Rayon path, invisible to the tick thread. T-1044 confirmed within-cascade-only (cascade.rs:358 still drops the analysis before BodyWorldState) — the fork was open. Window derive loop promoted from aliveness_probe::render_window_panels; determinism promoted from probe-only proof to a real test (two passes byte-identical). New fixture atlas_response_ready_with_window exercises all six arrays incl. the airless sentinel and Marine; three existing fixtures gain district_window: None. Server clamps window_n to 1..=DISTRICT_WINDOW_MAX_N=64 (never trust the wire). 1774/1774 lib + 19/19 bridge_tcp green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
ˆ§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ
|
||||
‰§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ¯district_windowÀ²quarter_footprintsÀ
|
||||
@@ -1 +1 @@
|
||||
êbody_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements屨region_grid徽quarter_footprints�
|
||||
�body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements屨region_grid嶸district_window徽quarter_footprints�
|
||||
Binary file not shown.
Binary file not shown.
@@ -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<BuildingPropertyTag>,
|
||||
},
|
||||
/// 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<BodyParams>,
|
||||
/// 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<FilledChunk>,
|
||||
},
|
||||
/// 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<DistrictWindowLayer>,
|
||||
},
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+684
-11
@@ -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<DistrictPos>,
|
||||
/// 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<DistrictWindowLayer>` 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<RegionGridLayer>,
|
||||
/// 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<DistrictWindowLayer>,
|
||||
/// 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<u8>,
|
||||
/// 0-100, matches `DistrictGridLayer.elev_q` encoding.
|
||||
pub elev_q: Vec<u8>,
|
||||
/// 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<i16>,
|
||||
/// 0-100, matches `DistrictGridLayer` precedent.
|
||||
pub moisture_q: Vec<u8>,
|
||||
/// `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<u8>,
|
||||
/// `GlaciationGrade` discriminant, 0-4 (T-1127).
|
||||
pub glaciation: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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<DistrictWindowKey, DistrictWindowLayer>,
|
||||
/// Insertion order, oldest first — the eviction queue.
|
||||
order: std::collections::VecDeque<DistrictWindowKey>,
|
||||
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<u8>`/`Vec<i16>` 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<SettlementLayer>
|
||||
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<DistrictWindowLayer> {
|
||||
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<SettlementLayer>
|
||||
/// 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);
|
||||
|
||||
|
||||
@@ -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<AtlasRequestBuffer>,
|
||||
mut responses: ResMut<AtlasResponseBuffer>,
|
||||
mut cache: ResMut<BodyWorldStateCache>,
|
||||
mut window_cache: ResMut<DistrictWindowCache>,
|
||||
queue: Res<GenerationQueue>,
|
||||
resolver: Option<Res<BodySourceResolverResource>>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
@@ -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<GenerationQueue>,
|
||||
mut cache: ResMut<BodyWorldStateCache>,
|
||||
mut window_cache: ResMut<DistrictWindowCache>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
|
||||
rng: Option<Res<SimRng>>,
|
||||
@@ -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::<GenerationQueue>().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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user