//! Background generation queue — prioritized Rayon thread pool (D-206). //! //! All runtime-background generation work runs through this queue. The main //! tick thread submits work items (non-blocking) and drains completion events //! once per tick via a `crossbeam` channel. //! //! **Priority levels (D-206):** //! - `Immediate`: player arrives within 1 game-minute. Runs first. //! - `High`: player arrives within 5 game-minutes. //! - `Medium`: player is in the same system. //! - `Low`: player has heard of this location via NPC/news. //! //! **Work item types (D-206):** //! - `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 //! system in `TickPhase::PreInput`. //! //! **Thread count (D-206):** `available_parallelism - 2`, minimum 1. use std::path::PathBuf; use std::sync::{Arc, Mutex}; use bevy_ecs::prelude::Resource; 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, ClimateConstants, DistrictPos}; use crate::atlas::features::TerrainAnalysis; 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}; // --------------------------------------------------------------------------- // Priority // --------------------------------------------------------------------------- /// Work priority levels — lower discriminant = higher priority. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum GenPriority { /// Player arrives within ~1 game-minute. Runs before all other levels. Immediate = 0, /// Player arrives within ~5 game-minutes. High = 1, /// Player is in the same system. Medium = 2, /// Player has seen or heard of this location (NPC dialogue, news ticker). Low = 3, } // --------------------------------------------------------------------------- // Work item types // --------------------------------------------------------------------------- /// A unit of background generation work (D-206). #[derive(Debug, Clone)] pub enum GenWorkItem { /// Run the Layer-1 cascade (drainage → features → sub-biome) for this body. /// The enqueuer resolves the inputs (D-225): `heightmap_path` is the /// mod-resolved source PNG, `body_seed` is this body's SeedChain position. /// `run_work_item` is pure compute — it does no path/DB resolution. AnalyzeBody { body_id: String, heightmap_path: PathBuf, sea_level: f32, body_seed: SeedChain, /// The body's settlements (from `atlas_city_names`), pre-resolved at /// dispatch time so the cascade stays DB-free. Fed to Layer-3 placement /// (#955); empty if the body has no settlements (cascade stops at Layer 1). cities: Vec, /// The body's system `dominant_faction` (D-237), pre-resolved at dispatch /// time. Drives Layer-3 TerritorialStatus + spatial character (#956, /// D-212/214/215). `None` → `FrontierUnclaimed`. dominant_faction: Option, /// Body physical parameters for the DistrictProfile layer (T-1023, D-239 §1). /// Pre-resolved at dispatch time. `None` → district layer skipped for this body. /// Boxed: `BodyParams` is large relative to other variants (clippy /// large_enum_variant) — boxing keeps `GenWorkItem` compact. body_params: Option>, }, /// Generate a Phase 1 QuarterSkeleton for this city. /// /// `context` is the D-199 economic read-set pre-resolved at dispatch time. /// All 6 required fields must be populated before this item is submitted /// (D-199: "Missing fields abort the task … generation does not proceed with /// partial context"). /// /// `body_id` routes the resulting `SkeletonGenerated` completion into the /// correct `BodyWorldState` cache entry (D-230). /// /// `quarter_id` is the stable content-addressable id for the generated /// quarter (keyed by city position + world seed). /// /// `economic_role`, `population`, and `founding_age_years` are D-199 fields /// carried alongside the context because `generate_quarter_skeleton` accepts them as /// separate parameters (its signature is not changed by this ticket). GenerateSkeleton { city_id: u64, body_id: String, /// D-199 economic read-set + all other context fields. context: Box, /// Stable content-addressable quarter id (D-194/D-230). quarter_id: u64, /// Quarter-level seed chain (D-224). chain: SeedChain, // D-199 raw fields passed to generate_quarter_skeleton separately. economic_role: String, population: i64, founding_age_years: u32, /// D-235 exterior-grammar content (T-988): the trait-template catalog's /// `visual_bundle`s plus the two sibling content tables /// (`architecture_zone_bias`, `color_register_bands`), pre-resolved at /// dispatch time — mirrors `context`'s own pre-resolution rationale, so /// `assign_block_tags` never touches `systems.db` (T-987/D-230 purity). /// A `Vec`/`BTreeMap`-backed struct is a handful of words on the stack /// regardless of its contents' size (same reasoning already documented /// for `FillChunk.block_tags` below), so this needs no `Box`. exterior_catalog: ExteriorCatalog, }, /// Derive the building shell for one 64 m chunk of an existing quarter /// (D-230 derive phase, T-987). /// /// The covering block's `block_tags` are **pre-resolved into the item** at enqueue /// time because `run_work_item` is cache-free (mirrors `GenerateSkeleton`). A 64 m /// chunk lies wholly within one 128 m block and footprints are block-confined, so /// the covering block's tags are exactly the relevant set. Build items with /// [`build_fill_chunk_item`] — the D-230 "skeleton not yet processed → re-enqueue /// at `High`" precondition is the caller's cache lookup, which only reaches this /// constructor once the `QuarterWorldState` exists. FillChunk { /// Stable id of the quarter being filled (D-194/D-230). quarter_id: u64, /// Block grid position within the quarter (0..4, 0..4). block_pos: (u8, u8), /// Sub-chunk quadrant within the block (0..2, 0..2) — a block is 2×2 chunks. sub_chunk: (u8, u8), /// Covering block's building tags, pre-resolved from the cached /// `QuarterWorldState`. Empty for an open/un-built block (→ empty shell). /// A `Vec` is three words on the stack regardless of element size (the tags /// live behind the pointer), so — unlike `AnalyzeBody`'s boxed `BodyParams` — /// 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; /// corrected 2026-07-21 per PR #187 review — Tyre C1):** `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. So `run_work_item` re-derives /// via `run_layer1` (matching `aliveness_probe`'s existing `--render` /// workaround) rather than persisting a field on `BodyWorldState` — but /// NOT unconditionally on every work item: the actual model is a small /// **per-body LRU** (`TerrainAnalysisCache`, capacity 8, ~16 MB worst /// case), consulted before every re-derive. The FIRST `DeriveWindow` on a /// body pays the full ~45 ms `run_layer1` cost and populates that body's /// cache entry; EVERY SUBSEQUENT window on the SAME body (any `center`/`n`, /// not just an exact repeat — that narrower case is what /// `DistrictWindowCache` in `layer_proxy.rs` already catches) hits the LRU /// and skips straight to the ~7–29 ms per-window pack in /// `build_district_window_layer`. An LRU eviction re-pays the ~45 ms on the /// next window for that body. Because every `DeriveWindow` work item still /// runs off the tick thread on the Rayon queue regardless of hit or miss, /// both costs are invisible to the main thread either way — the LRU's /// value is throughput/worker-occupancy (bounding how many ~45 ms re-derives /// a pan-burst across one body can force), not tick-thread latency. 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]` AND the granularity-aware /// `WIRE_CAP_CELLS` ceiling by the caller (`handle_atlas_request`) /// before this item is built — never trusted from the wire again here. center: DistrictPos, n: u32, /// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1) /// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via /// `resolve_window_granularity` by the caller. granularity: u32, /// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff. min_wl_m: u32, }, } impl GenWorkItem { pub fn body_id(&self) -> Option<&str> { match self { GenWorkItem::AnalyzeBody { body_id, .. } => Some(body_id), _ => None, } } /// Coalescing key for `DeriveWindow` items only — `(connection, body, /// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending /// T-1137's `(connection, body)`). `granularity` is part of the key so an /// in-flight district-spacing (granularity 1) pan-burst is never /// superseded by an unrelated quarter-spacing (granularity 4) request for /// the same connection+body, and vice versa — the two rungs are separate /// in-flight derives, not competing updates to the same one. /// `None` for every other variant (they don't coalesce this way). pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> { if let GenWorkItem::DeriveWindow { body_id, conn_id, granularity, .. } = self { Some((*conn_id, body_id, *granularity)) } else { None } } } // --------------------------------------------------------------------------- // Completion event // --------------------------------------------------------------------------- /// Sent back to the main thread when a work item finishes (D-206). #[derive(Debug)] pub enum GenCompletion { BodyAnalyzed { body_id: String, /// The computed world state, ready for `BodyWorldStateCache::insert`. state: BodyWorldState, }, SkeletonGenerated { city_id: u64, /// The body this skeleton belongs to — used to route state into /// `BodyWorldState.quarters` (D-230). body_id: String, /// District-level world state (skeleton + block tags) produced by the plan phase (D-230). /// Boxed to keep `GenCompletion` variant sizes balanced (D-230 skeleton is ~2.7 KB). state: Box, }, ChunkFilled { /// The derived shell for this chunk (D-230, T-987). Sparse — only the /// non-`Void` shell voxels. Carries its own quarter/block/sub-chunk address. /// 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 }, } // --------------------------------------------------------------------------- // Internal queued work // --------------------------------------------------------------------------- struct QueuedWork { priority: GenPriority, item: GenWorkItem, } // --------------------------------------------------------------------------- // GenerationQueue — Bevy Resource // --------------------------------------------------------------------------- /// Bevy `Resource` managing the background generation queue (D-206). /// /// Submit work with `submit()`. Drain completions with `drain_completions()` /// once per tick. The Rayon thread pool runs tasks in priority order. /// /// Also owns the queue-scoped `TerrainAnalysisCache` (T-1137, PR #187 review /// C1) — a small per-body LRU consulted by every `DeriveWindow` work item /// before paying the `run_layer1` re-derive cost. Lives here (not as a /// separate `Resource`) because it must be reachable from `run_work_item` /// while it executes on a Rayon worker thread, the same reason /// `in_flight`/`in_flight_count` are `Arc>` fields on this struct /// rather than plain fields. /// /// Priority is respected because `dispatch_next()` is gated on pool saturation /// via `in_flight_count`: it only dispatches when fewer than `n_threads` tasks /// are running. This applies to all work item types — `in_flight` (body-id set) /// is only for AnalyzeBody dedup; `in_flight_count` is the general saturation gate. #[derive(Resource)] pub struct GenerationQueue { /// Pending work items, sorted by priority (index 0 = highest priority). pending: Arc>>, /// Completions channel — background tasks send here; main thread reads. completion_tx: Sender, completion_rx: Receiver, /// Rayon thread pool dedicated to generation work. pool: rayon::ThreadPool, /// Set of body_ids currently in-flight — used only for AnalyzeBody dedup. in_flight: Arc>>, /// Count of all work items currently executing in the Rayon pool. /// This is the saturation gate — all work item types increment/decrement it. in_flight_count: Arc>, /// Thread count — caps concurrent dispatches so pending items accumulate /// and priority ordering is consulted before the pool has free threads. n_threads: usize, /// Per-body `TerrainAnalysis` LRU shared by every `DeriveWindow` work item /// on this queue (T-1137, PR #187 review C1) — see [`TerrainAnalysisCache`]. terrain_cache: Arc>, } impl std::fmt::Debug for GenerationQueue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let pending_len = self.pending.lock().map(|p| p.len()).unwrap_or(0); f.debug_struct("GenerationQueue") .field("pending_count", &pending_len) .finish() } } impl GenerationQueue { /// Create a new queue with the D-206 thread count: /// `available_parallelism - 2`, minimum 1. pub fn new() -> Self { let n_threads = std::thread::available_parallelism() .map(|p| p.get().saturating_sub(2).max(1)) .unwrap_or(1); Self::with_threads(n_threads) } /// Create a queue with a specific thread count (for testing). pub fn with_threads(n_threads: usize) -> Self { let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_threads) .thread_name(|i| format!("gen-worker-{i}")) .build() .expect("failed to build generation rayon pool"); let (tx, rx) = crossbeam_channel::unbounded(); Self { pending: Arc::new(Mutex::new(Vec::new())), completion_tx: tx, completion_rx: rx, pool, in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())), in_flight_count: Arc::new(Mutex::new(0)), n_threads, terrain_cache: Arc::new(Mutex::new(TerrainAnalysisCache::new( TERRAIN_ANALYSIS_CACHE_CAPACITY, ))), } } /// Submit a work item at the given priority. /// /// If an `AnalyzeBody` item for the same body_id is already in-flight or /// pending, the submission is silently ignored (idempotent). pub fn submit(&self, item: GenWorkItem, priority: GenPriority) { // Dedup AnalyzeBody submissions. if let Some(body_id) = item.body_id() { let in_flight = self.in_flight.lock().unwrap(); if in_flight.contains(body_id) { return; } drop(in_flight); // Check pending list. let pending = self.pending.lock().unwrap(); if pending.iter().any(|q| q.item.body_id() == Some(body_id)) { return; } drop(pending); } let mut pending = self.pending.lock().unwrap(); let pos = pending .iter() .position(|q| q.priority > priority) .unwrap_or(pending.len()); pending.insert(pos, QueuedWork { priority, item }); drop(pending); self.dispatch_next(); } /// Submit a `DeriveWindow` item with per-connection coalescing (D-226 /// T-1124 amendment §1, "recommended"; extended T-1150 to key on /// granularity too): if a `DeriveWindow` item for the SAME `(connection, /// body, granularity)` 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+granularity before the first is dispatched collapses /// to one derive. A district-spacing and quarter-spacing request for the /// same connection+body do NOT coalesce with each other — they're /// separate in-flight derives, not competing updates to the same rung. /// /// 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(), key.2); let mut pending = self.pending.lock().unwrap(); pending.retain(|q| { q.item .window_supersede_key() .map(|k| (k.0, k.1.to_string(), k.2) != 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 /// available without blocking. After draining, dispatches as many pending /// items as there are free thread slots — this is the point where priority /// ordering matters, since the pool was saturated when items were submitted. pub fn drain_completions(&self) -> Vec { let mut out = Vec::new(); while let Ok(c) = self.completion_rx.try_recv() { out.push(c); } // Fill any newly-freed slots. for _ in 0..out.len() { self.dispatch_next(); } out } /// Number of items waiting in the pending queue. pub fn pending_count(&self) -> usize { self.pending.lock().unwrap().len() } // Dispatch the highest-priority pending item to the Rayon pool. // // Gated on in_flight_count < n_threads — applies to all work item types, // not just AnalyzeBody. When the pool is full, items stay in the sorted // pending Vec so priority ordering is consulted on the next free slot. fn dispatch_next(&self) { let item = { let count = self.in_flight_count.lock().unwrap(); if *count >= self.n_threads { return; } drop(count); let mut pending = self.pending.lock().unwrap(); if pending.is_empty() { return; } pending.remove(0).item }; // Mark body as in-flight (AnalyzeBody dedup). if let Some(body_id) = item.body_id() { self.in_flight.lock().unwrap().insert(body_id.to_string()); } // Increment general in-flight counter for all item types. *self.in_flight_count.lock().unwrap() += 1; let tx = self.completion_tx.clone(); let in_flight = Arc::clone(&self.in_flight); let in_flight_count = Arc::clone(&self.in_flight_count); let terrain_cache = Arc::clone(&self.terrain_cache); self.pool.spawn(move || { let completion = run_work_item(&item, &terrain_cache); // Un-mark body dedup set (AnalyzeBody only). if let Some(body_id) = item.body_id() { in_flight.lock().unwrap().remove(body_id); } // Decrement general counter for all item types. *in_flight_count.lock().unwrap() -= 1; let _ = tx.send(completion); }); } } impl Default for GenerationQueue { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) // --------------------------------------------------------------------------- /// Small per-body LRU of re-derived [`TerrainAnalysis`] (~1.5–2 MB/entry), /// consulted by the `DeriveWindow` execution path before paying the ~45 ms /// `run_layer1` re-derive cost (T-1137 binding decision — see the /// `DeriveWindow` variant doc on why `TerrainAnalysis` is re-derived rather /// than cached on `BodyWorldState` at all). /// /// **Why this exists (PR #187 review finding, binding):** the original /// T-1137 landing called `run_layer1` unconditionally on every `DeriveWindow` /// work item — nothing memoized it within a body, so the *dominant* usage /// pattern (panning around ONE body, many windows) paid the ~45 ms re-derive /// on every single window instead of just the first. This cache closes that /// gap: first window on a body pays the full re-derive and populates the /// entry; every subsequent window on the SAME body (until eviction) hits the /// cache and skips straight to the ~7–29 ms `build_district_window_layer` /// pack (§4's actual per-window number). /// /// **Shape:** `Arc>` — lives on [`GenerationQueue`] alongside /// `in_flight`/`in_flight_count` (the same "shared state cloned into every /// Rayon closure" pattern), because `run_work_item` executes ON a Rayon /// worker thread, potentially concurrently with other workers when /// `n_threads > 1`; a queue-owned, plain (non-`Resource`) cache is the /// correct home — `handle_atlas_request`'s `DistrictWindowCache` is main- /// thread-only (D-225 poll loop) and cannot be reused here. /// /// **Capacity (8, ~16 MB worst case):** deliberately small relative to /// `BodyWorldStateCache::CACHE_CAPACITY` (50) — this caches a re-derive /// shortcut for bodies actually being window-browsed RIGHT NOW, not a /// body-indexed store meant to grow with session length. True LRU /// (access-recency, mirroring `BodyWorldStateCache`'s own eviction policy) /// rather than `DistrictWindowCache`'s FIFO-by-insertion: unlike a /// D-227-pure derived window (valid forever, no recency signal to track), /// which body a player keeps panning around IS a recency signal, so /// access-order eviction is the right fit here. #[derive(Debug)] struct TerrainAnalysisCache { entries: std::collections::BTreeMap, /// Monotonic access counter (substitutes for `BodyWorldStateCache`'s /// `SimTick` — there is no tick concept on a background Rayon thread). clock: u64, capacity: usize, } /// Default capacity for [`TerrainAnalysisCache`] (PR #187 review — Tyre C1 /// binding numbers: "capacity ~8, ~2MB/entry = ~16MB worst case"). const TERRAIN_ANALYSIS_CACHE_CAPACITY: usize = 8; impl TerrainAnalysisCache { fn new(capacity: usize) -> Self { Self { entries: std::collections::BTreeMap::new(), clock: 0, capacity, } } /// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via /// `run_layer1` on a miss and inserting the result (evicting the LRU /// entry first if at capacity). Bumps the access clock on both a hit and /// a fresh insert (both are "this body was just used"). fn get_or_derive( &mut self, body_id: &str, heightmap: &crate::atlas::heightmap::BodyHeightmap, ) -> TerrainAnalysis { self.clock += 1; let now = self.clock; if let Some((ta, last_used)) = self.entries.get_mut(body_id) { *last_used = now; return ta.clone(); } let (_, ta) = crate::atlas::layer1::run_layer1(heightmap); if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) { if let Some(victim) = self .entries .iter() .min_by_key(|(_, (_, last_used))| *last_used) .map(|(id, _)| id.clone()) { self.entries.remove(&victim); } } self.entries.insert(body_id.to_string(), (ta.clone(), now)); ta } #[cfg(test)] fn len(&self) -> usize { self.entries.len() } #[cfg(test)] fn contains(&self, body_id: &str) -> bool { self.entries.contains_key(body_id) } } // --------------------------------------------------------------------------- // Work execution stub // --------------------------------------------------------------------------- /// Execute one work item. This is the Rayon task body (off the tick thread). /// /// `AnalyzeBody` runs the real Layer-1 cascade (#968, D-225); `GenerateSkeleton` /// runs the real plan phase (#957, D-229) producing the skeleton + block tags; /// `FillChunk` runs the real derive phase (T-987, D-230) producing the building /// shell from the pre-resolved tags. /// /// `terrain_cache` serves `DeriveWindow`'s `TerrainAnalysis` re-derive /// shortcut (T-1137, PR #187 review C1) — unused by every other variant /// (they don't touch `TerrainAnalysis` at all, or — `AnalyzeBody` — derive it /// once already as part of the normal in-cascade path, T-1044). fn run_work_item( item: &GenWorkItem, terrain_cache: &Arc>, ) -> GenCompletion { match item { GenWorkItem::AnalyzeBody { body_id, heightmap_path, sea_level, body_seed, cities, dominant_faction, body_params, } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { Ok(hm) => { // Layer 1 runs at the GRID_W×GRID_H working resolution (D-202): // downsample the higher-res stored heightmap first. let working = if hm.width > GRID_W || hm.height > GRID_H { hm.downsample(GRID_W, GRID_H) } else { hm }; // Run the full cascade through Region (T-1113), the terminal // layer. It subsumes RoadGraph (T-1038), Settlement, // DistrictProfile (T-1023), and all prior layers. // DistrictProfile and Region derivation both gate on // body_params internally (skipped when absent — e.g. a body // with no params row), but the road graph needs no body // params, so it runs for every analyzed body. let up_to = CascadeLayer::Region; let snapshot = run_cascade_from_heightmap( *body_seed, working, cities, dominant_faction.as_deref(), body_params.as_deref(), up_to, ); GenCompletion::BodyAnalyzed { body_id: body_id.clone(), state: snapshot.into_body_world_state(), } } Err(e) => GenCompletion::Failed { item: item.clone(), reason: format!("heightmap load failed: {e}"), }, }, GenWorkItem::GenerateSkeleton { city_id, body_id, context, quarter_id, chain, economic_role, population, founding_age_years, exterior_catalog, } => { // Build the Phase 1 skeleton from the pre-resolved D-199 context. // `economic_role`, `population`, and `founding_age_years` are the // D-199 raw fields carried alongside the context because // `generate_quarter_skeleton` accepts them as separate parameters. let skeleton = generate_quarter_skeleton( context, *population, economic_role, *quarter_id, *founding_age_years, *chain, ); // Step-3 building-property tags per footprint (D-229, #957): subdivide // each block into building plots and tag them. `exterior_catalog` // (D-235, T-988) resolves each tag's BuildingExteriorTag in the // same pass. let block_tags = assign_all_block_tags( &skeleton, context, economic_role, *founding_age_years, *chain, exterior_catalog, ); GenCompletion::SkeletonGenerated { city_id: *city_id, body_id: body_id.clone(), state: Box::new(QuarterWorldState { skeleton, block_tags, }), } } GenWorkItem::FillChunk { quarter_id, block_pos, sub_chunk, block_tags, } => { // D-230 derive phase: pure rectangle-containment + z-range shell fill over // the pre-resolved tags. No cache read here — that is what keeps FillChunk // trivially fast and re-derivable (D-227). let filled = fill_chunk(*quarter_id, *block_pos, *sub_chunk, block_tags); GenCompletion::ChunkFilled { 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, granularity, min_wl_m, } => 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 }; // TerrainAnalysis via the per-body LRU (T-1137 binding decision + // PR #187 review C1): first window on a body pays the ~45 ms // run_layer1 re-derive and populates the cache entry; every // subsequent window on the SAME body (until eviction) hits the // cache and skips straight to the ~7–29 ms per-window pack below. // This is the memoized form of the SAME workaround // aliveness_probe --render uses when CascadeSnapshot.terrain_analysis // is None (it has no cache — a one-shot CLI run doesn't need one). let ta = terrain_cache .lock() .unwrap() .get_or_derive(body_id, &working); let climate = ClimateConstants::default(); let layer = build_district_window_layer( *body_seed, body_id, body_params, &ta, *center, *n, &climate, *granularity, *min_wl_m, ); GenCompletion::WindowDerived { body_id: body_id.clone(), layer: Box::new(layer), } } Err(e) => GenCompletion::Failed { item: item.clone(), reason: format!("heightmap load failed: {e}"), }, }, } } /// Build a [`GenWorkItem::FillChunk`] for one 64 m sub-chunk of a quarter, pulling the /// covering block's tags out of the cached `QuarterWorldState` (D-230 derive phase, T-987). /// /// Pure (no queue/cache handle), so it unit-tests without a running app. The D-230 /// precondition — "`FillChunk` is only dispatched after `SkeletonGenerated` for that /// district has been processed; if absent, re-enqueue at `High`" — is the caller's /// cache lookup: this constructor only runs once the `QuarterWorldState` exists. A /// block with no buildings yields empty `block_tags` (→ an empty, terrain-only shell), /// which is a valid ready state, not a not-yet-generated one. pub fn build_fill_chunk_item( quarter: &QuarterWorldState, block_pos: (u8, u8), sub_chunk: (u8, u8), ) -> GenWorkItem { let block_tags = quarter .block_tags .get(&block_pos) .cloned() .unwrap_or_default(); GenWorkItem::FillChunk { quarter_id: quarter.skeleton.quarter_id, block_pos, sub_chunk, block_tags, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::time::Duration; fn make_queue() -> GenerationQueue { GenerationQueue::with_threads(2) } /// Write a tiny 16-bit grayscale heightmap PNG to a unique temp path so the /// real cascade can run in `run_work_item` without a committed fixture. fn test_heightmap_path() -> std::path::PathBuf { use std::io::BufWriter; use std::sync::atomic::{AtomicU32, Ordering}; static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_genq_{}_{n}.png", std::process::id())); let file = std::fs::File::create(&path).expect("create test heightmap"); let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16); enc.set_color(png::ColorType::Grayscale); enc.set_depth(png::BitDepth::Sixteen); let mut w = enc.write_header().expect("png header"); let data: Vec = (0..32u32 * 16) .flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes()) .collect(); w.write_image_data(&data).expect("png data"); path } /// Build an `AnalyzeBody` work item pointing at a tiny test heightmap. fn analyze(body_id: &str) -> GenWorkItem { GenWorkItem::AnalyzeBody { body_id: body_id.to_string(), heightmap_path: test_heightmap_path(), sea_level: 0.3, body_seed: SeedChain::for_body(42, body_id), cities: vec![], dominant_faction: None, body_params: None, // T-1023: no body params in queue-mechanic unit tests } } /// Build a minimal `GenerateSkeleton` work item with a stub context. /// /// The stub context uses Commission/Regional/Urban defaults — the same /// values the existing skeleton_gen tests use. These tests exercise queue /// mechanics (ordering, saturation, drain), not economic read-set content. fn gen_skeleton(city_id: u64) -> GenWorkItem { use crate::simulation::generator::{ BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, ProductionUbiquity, SettingType, WorldTier, }; GenWorkItem::GenerateSkeleton { city_id, body_id: format!("TestBody{city_id}"), context: Box::new(CityGenerationContext { city_id, political_archetype: PoliticalArchetype::Commission, prosperity_baseline_bps: 6_000, surrounding_biome: SettingType::Urban, road_entry_directions: vec![], footprint_radius_km: 5.0, founding_orientation: FoundingOrientation::Cardinal, world_tier: WorldTier::Regional, morphology_zone: MorphologyZone::AlluvialPlain, trait_selection: vec![], dominant_bulk_class: BulkClass::NonPhysical, dominant_production_ubiquity: ProductionUbiquity::Common, geographic_sector: None, body_district_type_mix: vec![], settlement_district_pos: (0, 0), district_dominant_by_type: Default::default(), swerve_rates_bps: (0, 0), swerve_foreign_pool: vec![], swerve_heritage_pool: vec![], }), quarter_id: city_id * 10, chain: SeedChain::root(42 + city_id), economic_role: "service_mixed".to_string(), population: 500_000, founding_age_years: 200, exterior_catalog: ExteriorCatalog::default(), } } #[test] fn submit_and_drain() { let q = make_queue(); q.submit(analyze("TestBody"), GenPriority::Medium); // Give Rayon time to load the heightmap and run the cascade. std::thread::sleep(Duration::from_millis(100)); let completions = q.drain_completions(); assert_eq!(completions.len(), 1); // The work item ran the real cascade and produced a populated state. assert!(matches!( &completions[0], GenCompletion::BodyAnalyzed { body_id, state } if body_id == "TestBody" && state.heightmap_width == 32 && state.heightmap_height == 16 )); } #[test] fn dedup_analyze_body() { let q = make_queue(); // Submit the same body twice before it can complete. q.submit(analyze("Dup"), GenPriority::Low); q.submit(analyze("Dup"), GenPriority::Low); std::thread::sleep(Duration::from_millis(50)); let completions = q.drain_completions(); // Should have completed exactly once. assert_eq!(completions.len(), 1); } #[test] fn priority_ordering() { // Submit three items rapidly; Immediate should be dispatched first. // Uses 3 threads so all items can dispatch without hitting saturation. let q = GenerationQueue::with_threads(3); // Using GenerateSkeleton (no dedup logic) to test ordering directly. q.submit(gen_skeleton(1), GenPriority::Low); q.submit(gen_skeleton(2), GenPriority::Immediate); q.submit(gen_skeleton(3), GenPriority::Medium); std::thread::sleep(Duration::from_millis(100)); let completions = q.drain_completions(); assert_eq!(completions.len(), 3); } #[test] fn priority_ordering_respected_under_saturation() { // Single-thread queue: in_flight_count saturates at 1, so the second // item stays in the pending Vec and is dispatched in priority order. // Uses AnalyzeBody (distinct body_ids) so all paths — dedup set AND // in_flight_count — are exercised. let q = GenerationQueue::with_threads(1); // Submit Low first, then Immediate. With 1 thread: // - "BodyA" (Low) dispatches immediately (pool empty). // - "BodyB" (Immediate) is inserted at index 0 of the sorted pending // Vec while "BodyA" is in-flight (in_flight_count = 1 = n_threads). // - When "BodyA" completes, drain_completions() calls dispatch_next() // which picks index 0 = "BodyB" (Immediate). q.submit(analyze("BodyA"), GenPriority::Low); q.submit(analyze("BodyB"), GenPriority::Immediate); // Wait for BodyA to complete. std::thread::sleep(Duration::from_millis(50)); // drain_completions dispatches BodyB (Immediate, index 0 of pending). let first = q.drain_completions(); // Wait for BodyB to complete. std::thread::sleep(Duration::from_millis(50)); let second = q.drain_completions(); assert_eq!(first.len(), 1); assert_eq!(second.len(), 1); assert!( matches!(&first[0], GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "BodyA") ); assert!( matches!(&second[0], GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "BodyB") ); } #[test] fn drain_empty_returns_empty() { let q = make_queue(); let result = q.drain_completions(); assert!(result.is_empty()); } #[test] fn pending_count_decreases_after_completion() { let q = make_queue(); q.submit( GenWorkItem::FillChunk { quarter_id: 99, block_pos: (0, 0), sub_chunk: (0, 0), block_tags: vec![], }, GenPriority::High, ); std::thread::sleep(Duration::from_millis(50)); let completions = q.drain_completions(); assert!(!completions.is_empty() || q.pending_count() == 0); } /// A `QuarterWorldState` with one building in block (0,0), used to exercise the /// full FillChunk path (build item from cached state → Rayon → completion). fn quarter_with_one_building() -> QuarterWorldState { use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, BuildingPropertyTag, ConstructionEra, EraCause, FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, QuarterSkeleton, RoofForm, SetbackTier, StreetSurface, TileRect, WallMaterial, ZoneTypeId, }; use std::collections::BTreeMap; let mut block_tags: BTreeMap<(u8, u8), Vec> = BTreeMap::new(); block_tags.insert( (0, 0), vec![BuildingPropertyTag { zone_type_id: ZoneTypeId::new("residential_low"), footprint: TileRect::new(4, 4, 6, 6), extent: FloorExtent { base_floor: 0, floor_count: 2, heights: FloorHeightProfile::Uniform(3), }, entry_class: BuildingEntryClass::Public, flavor_ref: ArchitectureFlavorRef::InVocabulary(0), era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, exterior: BuildingExteriorTag { wall_material: WallMaterial::Generic, roof_form: RoofForm::Generic, facade_rhythm: FacadeRhythm::Generic, setback_tier: SetbackTier::Standard, color: HsvColor { hue: 0, sat: 0, val: 5_000, }, street_surface: StreetSurface::Generic, }, doors: Vec::new(), }], ); QuarterWorldState { skeleton: QuarterSkeleton { quarter_id: 4242, ..Default::default() }, block_tags, } } #[test] fn build_fill_chunk_item_pulls_block_tags() { let quarter = quarter_with_one_building(); let item = build_fill_chunk_item(&quarter, (0, 0), (0, 0)); let GenWorkItem::FillChunk { quarter_id, block_pos, sub_chunk, block_tags, } = item else { panic!("expected FillChunk"); }; assert_eq!(quarter_id, 4242); assert_eq!(block_pos, (0, 0)); assert_eq!(sub_chunk, (0, 0)); assert_eq!( block_tags.len(), 1, "covering block's tags must be resolved" ); // A block with no buildings is a valid empty fill, not a missing-skeleton error. let empty = build_fill_chunk_item(&quarter, (3, 3), (0, 0)); let GenWorkItem::FillChunk { block_tags, .. } = empty else { panic!("expected FillChunk"); }; assert!(block_tags.is_empty(), "empty block → empty tags"); } #[test] fn fill_chunk_round_trip_produces_populated_shell() { let q = make_queue(); let quarter = quarter_with_one_building(); q.submit( build_fill_chunk_item(&quarter, (0, 0), (0, 0)), GenPriority::High, ); std::thread::sleep(Duration::from_millis(50)); let completions = q.drain_completions(); assert_eq!(completions.len(), 1); let GenCompletion::ChunkFilled { filled } = &completions[0] else { panic!("expected ChunkFilled, got {:?}", completions[0]); }; assert_eq!(filled.quarter_id, 4242); assert_eq!(filled.chunk_in_quarter(), (0, 0)); assert!( filled.voxel_count() > 0, "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, granularity: crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, min_wl_m: 0, } } /// 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" ); } // ------------------------------------------------------------------- // TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) // ------------------------------------------------------------------- fn window_test_hm() -> crate::atlas::heightmap::BodyHeightmap { use crate::atlas::heightmap::BodyHeightmap; let (w, h) = (32u32, 16u32); 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, } } /// A miss re-derives and populates the entry; a subsequent hit for the /// SAME body returns an equal `TerrainAnalysis` (D-227: the same /// heightmap always derives to the same analysis) WITHOUT growing the /// cache — `len()` stays at 1, proving the second call short-circuited /// past `run_layer1` rather than deriving-then-overwriting. #[test] fn terrain_analysis_cache_hit_reuses_entry() { let mut cache = TerrainAnalysisCache::new(8); let hm = window_test_hm(); assert!(!cache.contains("BodyA")); let first = cache.get_or_derive("BodyA", &hm); assert_eq!(cache.len(), 1); assert!(cache.contains("BodyA")); let second = cache.get_or_derive("BodyA", &hm); assert_eq!( cache.len(), 1, "a hit must not insert a second entry for the same body" ); assert_eq!( first.ocean_mask, second.ocean_mask, "same heightmap → identical re-derived analysis (D-227)" ); assert_eq!(first.slope_deg, second.slope_deg); assert_eq!(first.elev_pct, second.elev_pct); } /// Different bodies get independent entries, and a capacity-2 cache /// evicts the LEAST-RECENTLY-USED entry — not insertion order — when a /// third body is derived. Mirrors `BodyWorldStateCache`'s own /// `update_last_accessed_on_get` precedent: touching "BodyA" again before /// the third insert must save it from eviction. #[test] fn terrain_analysis_cache_evicts_lru_not_fifo() { let mut cache = TerrainAnalysisCache::new(2); let hm = window_test_hm(); cache.get_or_derive("BodyA", &hm); cache.get_or_derive("BodyB", &hm); assert_eq!(cache.len(), 2); // Touch BodyA again — it is now the MOST recently used, so BodyB // (untouched since its own insert) is the true LRU victim. cache.get_or_derive("BodyA", &hm); // Insert a third body — capacity 2 forces an eviction. cache.get_or_derive("BodyC", &hm); assert_eq!(cache.len(), 2); assert!( cache.contains("BodyA"), "recently re-touched BodyA must survive eviction" ); assert!( !cache.contains("BodyB"), "BodyB (true LRU — untouched since its own insert) must be evicted" ); assert!(cache.contains("BodyC")); } /// End-to-end: two `DeriveWindow` work items for the SAME body, submitted /// through the real `GenerationQueue` (not the bare `TerrainAnalysisCache` /// unit above), share ONE `TerrainAnalysisCache` entry — the fix for the /// PR #187 review C1 finding (the original landing called `run_layer1` /// unconditionally on every `DeriveWindow`, so panning around one body /// paid the ~45 ms re-derive on every window instead of just the first). /// Both windows must still complete correctly (content assertions, not /// timing — a wall-clock assertion would be flaky); the cache-population /// count is the load-bearing proof of reuse. #[test] fn two_windows_on_same_body_share_one_terrain_analysis_entry() { let q = GenerationQueue::with_threads(2); let conn_a = ConnectionId(1); let conn_b = ConnectionId(2); // Two DIFFERENT connections so submit_window's coalescing (which // supersedes same-connection/same-body pending items) doesn't collapse // these into one work item — the point here is two DISTINCT completed // derives sharing the cache, not coalescing (already covered above). q.submit_window( derive_window("SharedBody", conn_a, (0, 0)), GenPriority::Immediate, ); q.submit_window( derive_window("SharedBody", conn_b, (10, 10)), GenPriority::Immediate, ); std::thread::sleep(Duration::from_millis(200)); let completions = q.drain_completions(); let windows: Vec<_> = completions .iter() .filter_map(|c| { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "SharedBody" { return Some(layer); } } None }) .collect(); assert_eq!(windows.len(), 2, "both windows must complete"); let centers: std::collections::BTreeSet<_> = windows.iter().map(|l| l.center).collect(); assert_eq!( centers, std::collections::BTreeSet::from([(0, 0), (10, 10)]), "both distinct windows survived, not coalesced" ); // The queue's own TerrainAnalysisCache must hold exactly ONE entry // for "SharedBody" — both derives shared it rather than each // re-deriving independently. let cache = q.terrain_cache.lock().unwrap(); assert_eq!( cache.len(), 1, "two DeriveWindow items for the same body must share one TerrainAnalysis entry" ); assert!(cache.contains("SharedBody")); } }