feat(simulation): activate background generation tier end-to-end (#968, D-206/D-225)

The D-206 queue (#924) and D-203 cache (#917) were built but never connected to
the running app — the whole background tier was inert. This closes the loop:
submit -> Rayon -> cascade -> completion -> drain -> cache.

- gen_queue.rs: GenWorkItem::AnalyzeBody now carries enqueuer-resolved inputs
  { body_id, heightmap_path, sea_level, body_seed: SeedChain } (D-225 boundary —
  run_work_item stays pure compute, no path/DB resolution). run_work_item runs
  the real cascade: load heightmap.png -> downsample to GRID_W×GRID_H working
  grid (D-202) -> run_layer1 -> BodyWorldState; load failure -> Failed.
  GenCompletion::BodyAnalyzed carries the computed BodyWorldState.
- cascade.rs: CascadeSnapshot::into_body_world_state() conversion.
- plugin.rs (new): GenerationPlugin registers GenerationQueue +
  BodyWorldStateCache and adds a PreInput drain system that inserts BodyAnalyzed
  states into the cache (off the Rayon workers — a cheap channel drain, never
  the ~45ms cascade). Wired into both the production and test app setups.

Tests run the real cascade on a tiny temp heightmap PNG (no committed fixture);
the plugin test proves the full submit->...->cache loop. The proxy (#969) is the
production submitter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 10:47:59 +02:00
co-authored by Claude Opus 4.7
parent 53263492ab
commit 8cedf670f0
5 changed files with 239 additions and 44 deletions
+116
View File
@@ -0,0 +1,116 @@
//! Generation tier plugin (#968, D-206) — wires the background generation queue
//! and the per-body world-state cache into the running app.
//!
//! Registers [`GenerationQueue`] and [`BodyWorldStateCache`] as resources and
//! adds a `PreInput` system that drains completed work each tick and inserts the
//! computed [`BodyWorldState`](crate::atlas::body_world_state::BodyWorldState)
//! into the cache. The queue's *submitter* is the atlas layer-stream proxy
//! (#969, D-225); this plugin closes the submit→Rayon→cascade→drain→cache loop.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
use crate::atlas::gen_queue::{GenCompletion, GenerationQueue};
use crate::tick_phases::TickPhase;
/// Wires the D-206 background generation tier into the app (#968).
pub struct GenerationPlugin;
impl Plugin for GenerationPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(GenerationQueue::new())
.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY))
.add_systems(
Update,
drain_generation_completions.in_set(TickPhase::PreInput),
);
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
/// channel drain + cache insert, never the ~45 ms cascade itself.
fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
) {
for completion in queue.drain_completions() {
match completion {
GenCompletion::BodyAnalyzed { state, .. } => cache.insert(state),
GenCompletion::Failed { item, reason } => {
tracing::warn!(?item, %reason, "background generation work item failed");
}
// Produced once the later layers land (#957 / #959); no consumer yet.
GenCompletion::SkeletonGenerated { .. } | GenCompletion::ChunkFilled { .. } => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem};
use crate::seed::SeedChain;
use bevy_ecs::schedule::Schedule;
use std::time::Duration;
/// Tiny 16-bit grayscale heightmap PNG at a unique temp path, so the real
/// cascade can run 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_genplugin_{}_{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
}
#[test]
fn drain_system_populates_cache() {
// The full loop: submit → Rayon cascade → completion → drain → cache.
let mut world = World::new();
world.insert_resource(GenerationQueue::new());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.resource::<GenerationQueue>().submit(
GenWorkItem::AnalyzeBody {
body_id: "PlanetX".to_string(),
heightmap_path: test_heightmap_path(),
sea_level: 0.3,
body_seed: SeedChain::for_body(42, "PlanetX"),
},
GenPriority::Immediate,
);
let mut sched = Schedule::default();
sched.add_systems(drain_generation_completions);
// Rayon runs the cascade asynchronously; the drain runs each schedule pass.
let mut found = false;
for _ in 0..100 {
sched.run(&mut world);
if world.resource::<BodyWorldStateCache>().contains("PlanetX") {
found = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
found,
"drain system should insert the analyzed body into the cache"
);
}
}