Files
settled-reach/server/src/atlas/gen_queue.rs
T
jpmschweitzerandClaude Opus 4.8 def70eaf37 feat(simulation): D-239 three-carrier foundation (T-1023/1024/1026)
First foundation slice of the Atlas-to-tile derivation model (epic T-974),
building the carrier layer ahead of its T-1027+ consumers.

T-1026 — Anti-squaring domain warp (D-239 §4): stateless pure
fn(seed,body_id,pos)->(f64,f64), ±8m, f64 to the final voxel then as-i32
truncation for IEEE-754 cross-target determinism. New domain_warp.rs,
SeedDomain::DomainWarp; golden-vector + cross-thread tests. Position math
only — D-010 integer discipline preserved downstream. Marked dead_code
until the T-1028 VoxelColumn pipeline consumes it.

T-1023 — RegionProfile carrier (D-239 §1,§10): new RegionProfile +
TectonicClass/GlaciationGrade/PrecipitationClass enums + BodyParams; derived
per-region river_threshold replacing the global 200 for tile consumers.
regions: BTreeMap on BodyWorldState, populated via the cascade's new
RegionProfile layer (runs when body_params is Some, else falls back to
Settlement). D-010 integer discipline, BTree ordering.

T-1024 — District climate primitives (D-239 §2): nullable temperature_c +
moisture on RegionProfile, mean-annual scalar (no clock dep; dynamic branch
deferred to Q-105). Hybrid inputs — new bodies.axial_tilt_deg column imported
from planet-gen body-defs (populate_axial_tilt_deg, 2611 bodies), luminosity
and orbital distance derived at runtime; greenhouse + diurnal-swing tables in
source-canonical climate_constants.toml. D-239 implementation note added.

cargo test: 1498 passed, 0 failed. clippy clean (pre-existing
large_enum_variant only). make check-systems-db: stamp fresh.

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

634 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! 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::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::region_profile::BodyParams;
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
use crate::seed::SeedChain;
use crate::simulation::generator::{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<CityRecord>,
/// 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<String>,
/// Body physical parameters for the RegionProfile layer (T-1023, D-239 §1).
/// Pre-resolved at dispatch time. `None` → region layer skipped for this body.
/// Boxed: `BodyParams` is large relative to other variants (clippy
/// large_enum_variant) — boxing keeps `GenWorkItem` compact.
body_params: Option<Box<BodyParams>>,
},
/// 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<CityGenerationContext>,
/// 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,
},
/// Pre-fill a chunk in an existing quarter.
FillChunk {
quarter_id: u64,
block_pos: (u32, u32),
},
}
impl GenWorkItem {
pub fn body_id(&self) -> Option<&str> {
if let GenWorkItem::AnalyzeBody { body_id, .. } = self {
Some(body_id)
} 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<QuarterWorldState>,
},
ChunkFilled {
quarter_id: u64,
block_pos: (u32, u32),
},
/// 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.
///
/// 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<Mutex<Vec<QueuedWork>>>,
/// Completions channel — background tasks send here; main thread reads.
completion_tx: Sender<GenCompletion>,
completion_rx: Receiver<GenCompletion>,
/// 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<Mutex<std::collections::BTreeSet<String>>>,
/// 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<Mutex<usize>>,
/// Thread count — caps concurrent dispatches so pending items accumulate
/// and priority ordering is consulted before the pool has free threads.
n_threads: usize,
}
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,
}
}
/// 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();
}
/// 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<GenCompletion> {
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);
self.pool.spawn(move || {
let completion = run_work_item(&item);
// 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()
}
}
// ---------------------------------------------------------------------------
// 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`
/// and `FillChunk` remain stubs — their layers (#957 / #959) are not built yet —
/// returning immediate success so the queue infrastructure stays testable.
fn run_work_item(item: &GenWorkItem) -> 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 through RegionProfile (T-1023, D-239 §1): includes Settlement
// and all prior layers. RegionProfile > Settlement in CascadeLayer ord
// so Settlement also runs when body_params is Some. When body_params
// is None the cascade falls back to Settlement as the terminal layer.
let up_to = if body_params.is_some() {
CascadeLayer::RegionProfile
} else {
CascadeLayer::Settlement
};
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,
} => {
// 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.
let block_tags = assign_all_block_tags(
&skeleton,
context,
economic_role,
*founding_age_years,
*chain,
);
GenCompletion::SkeletonGenerated {
city_id: *city_id,
body_id: body_id.clone(),
state: Box::new(QuarterWorldState {
skeleton,
block_tags,
}),
}
}
GenWorkItem::FillChunk {
quarter_id,
block_pos,
} => GenCompletion::ChunkFilled {
quarter_id: *quarter_id,
block_pos: *block_pos,
},
}
}
// ---------------------------------------------------------------------------
// 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<u8> = (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,
}),
quarter_id: city_id * 10,
chain: SeedChain::root(42 + city_id),
economic_role: "service_mixed".to_string(),
population: 500_000,
founding_age_years: 200,
}
}
#[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),
},
GenPriority::High,
);
std::thread::sleep(Duration::from_millis(50));
let completions = q.drain_completions();
assert!(!completions.is_empty() || q.pending_count() == 0);
}
}