Compare commits

...
3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.7 bd5355d0a5 docs(decisions): D-226 live-pause inspection harness (agent-navigable real-UI review)
A multi-layer debug/review harness that reuses the real client UI so a human and
an automated agent can inspect deterministic server-computed state by attaching
to the live (auto-pausing) server. Supersedes the offline file-dump idea from
the D-225 discussion — the review tool is the production tool, no divergence,
no stale dumps.

Stack (bottom-up): live-pause substrate (TickRate::Paused + paused-allowlist,
triggered by gameplay_occluded; freezes world phases, keeps the bridge/gen-drain
alive) -> layer-stream proxy (D-225) -> human-visual viewer (#960, additive
overlays; shape=attractor type, color=sub-biome) -> agent channel
(AtlasAgentInterface: JSON observe + named-intent act, headless) -> interactive
capture (reuses the existing tests/run-visual primitive; complements, does not
supersede, the visual-golden regression role).

Consumers: Layer-1 geography now; economics + save-state inspection adopt the
pattern in their phases. Designed with Tyre (channel/pause/headless) + Araminta
(overlay encoding + affordance UX). Rides existing seams — a naming-and-contract
exercise, not a new subsystem.

Tickets: #970 auto-pause, #969 proxy, #960 viewer, #971 agent channel,
#972 capture (build order bottom-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:24:00 +02:00
jpmschweitzerandClaude Opus 4.7 8cedf670f0 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>
2026-05-24 10:47:59 +02:00
jpmschweitzerandClaude Opus 4.7 53263492ab docs(decisions): D-225 atlas layer-stream proxy — resolves Q-098, adds Q-099
Resolves how per-body cascade layer data reaches the Godot Atlas viewer (#960),
on the lead's mod-first directive + Tyre's design pass:
- No bake (privileges first-party content + ~100MB install bloat). A server-side
  layer-stream proxy computes on demand from moddable source files and streams
  Layer1Output to the client.
- Transport: additive message tag on the existing IPC stream (no second socket);
  single framed MessagePack response; raster stays a disk load.
- Mod-first BodySourceResolver: mod dirs override base install; heightmap.png is
  the sole source of truth.
- Cache via the D-206 background queue on miss (never blocks the tick thread,
  D-203); eviction -> recompute (deterministic, ~45ms).
- Whole Layer1Output per response; client composites additive overlays.

Q-098 resolved against its own premise (no durable store needed — recompute on
eviction is fine for a viewer). Q-099 spun off: mods adding new bodies need
terrain_reference rows in the binary systems.db (D-189), out of scope for #960.

Implementation decomposed into #968 (activate AnalyzeBody cascade run + cache
populate — the long pole) -> #969 (proxy + bridge protocol) -> #960 (viewer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:39:41 +02:00
7 changed files with 300 additions and 47 deletions
+50 -1
View File
@@ -1481,4 +1481,53 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*84 decisions (D-001 through D-224, excluding gaps). Last updated: 2026-05-23 (D-224 — SeedChain seed-derivation contract)*
### D-225: Atlas layer-stream proxy — compute-on-demand, mod-first (resolves Q-098)
- **Date:** 2026-05-23
- **Decision:** How per-body generation-cascade layer data reaches the Godot client for the Phase-4 Atlas progress viewer (#960). **Resolves Q-098.** Derived layer data is **never baked** into the install — that would both bloat the install (~100MB+ for ~267 bodies) and make modded bodies second-class. Instead a server-side **layer-stream proxy** computes on demand from moddable source files and streams to the client:
**(1) Transport — existing IPC stream + additive message tag.** Not a second socket, not a per-frame envelope rewrite. The bridge today carries no message-type discriminator (server→client is always `ObserverSnapshot`, client→server always `Vec<PlayerInput>`). The atlas request/response ride the *same* TCP stream as new message types, disambiguated **structurally** in v1 (a snapshot has `entities`/`tick`; the atlas messages do not). A full `BridgeMessage` envelope-everywhere migration is deferred — it would be a needless wire break, and client+server co-ship (D-005/D-192) so it can be done later as cleanup.
**(2) Single framed MessagePack response, not chunked.** `Layer1Output` is ~hundreds of KB worst case (512×256), well under the 16 MB frame cap. The **raster is not streamed** — the client already loads `reliefmap.png` from disk; the proxy streams only the *computed* `Layer1Output` (rivers, basins, attractors + sub-biome). MessagePack matches the rest of the bridge.
**(3) Mod-first source resolution.** A new `BodySourceResolver` with an ordered search: mod dirs (override) → base install (`wiki/...`, the floor). It reads the body's relative `terrain_reference` from `systems.db` (read-only) and returns the first existing `<root>/<terrain_reference>`. First-party and mod bodies flow through the **identical** resolve→`run_cascade`→stream path — the `heightmap.png` is the sole source of truth, no baked layer data. v1 wires the base root only; the resolver type + search order exist and are tested with a synthetic mod root, so the seam is mod-first from day one.
**(4) Cache via background queue on miss (D-203/D-206).** Request → `BodyWorldStateCache` lookup. Hit → serialize and return synchronously (a serialize, no compute). Miss → enqueue an `Immediate` `AnalyzeBody` on the `GenerationQueue` (D-206 background tier) and reply `Pending`; push the layer response when the completion lands. **The ~45 ms cascade must never run on the tick thread** (D-203: no blocking CPU/DB for heightmap data on main). The client shows the existing "TERRAIN DATA PENDING" panel until the response arrives. Eviction → recompute (always valid — determinism guaranteed).
**(5) Whole `Layer1Output` per response; client composites additive overlays.** The layers are produced together in one drainage pass, so per-layer requests save no compute and only add round-trips. Overlay toggles (heightmap + rivers + attractors + sub-biome shown *together*) are a pure client-side render concern (the existing `_overlay_visibility` pattern). The request carries `body_id` + `up_to_layer` (a forward-compat seam; v1 honors `Topography`).
- **Critical-path dependency:** the proxy is inert until `gen_queue.rs::run_work_item`'s `AnalyzeBody` actually runs `run_cascade` → builds `BodyWorldState` → populates the cache (today a documented stub, deferred from #142), and `GenCompletion::BodyAnalyzed` carries the computed state, not just `body_id`. This activation is the long pole and is tracked as its own ticket blocking #960.
- **Rationale:** Baking privileges first-party content (a mod body cannot ship baked artifacts it cannot produce) and adds install bloat. Computing from the moddable heightmap on demand — the cascade is deterministic and ~45 ms, and D-200/D-203/D-206 already provide the background-compute + LRU tiers — keeps mods first-class, adds zero storage, and uses the architecture as intended. Reusing the existing IPC stream (vs a second socket) avoids a parallel connection lifecycle for an occasional, user-initiated, latest-wins-irrelevant request.
- **Deferred / spun off:** the full `BridgeMessage` envelope-everywhere migration (later cleanup, not this ticket); the mod **content catalog** — mods adding *new* bodies need body rows + `terrain_reference` discoverable, but `systems.db` is binary / source-canonical (D-189) and mods cannot append to it → **Q-099**. D-225 resolves mod *file* resolution only; base-install resolution is enough to ship #960.
- **Implementation:** #960 (client viewer + proxy) plus the `AnalyzeBody` activation ticket. New surface: `server/src/atlas/source_resolver.rs`, `server/src/atlas/layer_proxy.rs`; `gen_queue.rs` `run_work_item` activation + `GenCompletion` payload; bridge receive/send branching; client `protocol.gd` / `sim_bridge.gd` / `atlas_viewer.gd`.
- **Raised by:** Jeroen (mod-first directive) + Tyre (design pass) + Claude, `/whats-next` refinement of #960, 2026-05-23.
- **Cross-reference:** Q-098 (resolved by this), Q-099 (mod content catalog — spun off), [D-191](#d-191) (Atlas viewer), [D-166](#d-166) (per-layer Atlas progress viewer), [D-200](#d-200) / [D-203](#d-203) (three-tier execution, LRU cache), [D-005](#d-005) / [D-192](#d-192) (client+server co-ship — no version handshake), [D-224](#d-224) (SeedChain — feeds the cascade the proxy runs)
- **Dissent:** None
---
### D-226: Live-pause inspection harness — agent-navigable, real-UI debug/review
- **Date:** 2026-05-24
- **Decision:** A multi-layer harness that reuses the **real client UI** so both a human *and* an automated agent can review/debug deterministic server-computed state, by attaching to the live (auto-pausing) server. It supersedes the offline file-dump idea floated during D-225: the review tool *is* the production tool (zero divergence), inspecting live data through the real path rather than stale dumped artifacts. The stack, built bottom-up:
**(1) Live-pause substrate.** The client attaches to the running server; entering a fullscreen implant app (the Atlas) **auto-pauses the sim**. Pause freezes the world-advancing tick phases (Movement / Simulation / Economy / Storyteller / Knowledge / TickAdvance) but **keeps PreInput (the gen-drain), Input (to receive resume), Snapshot and PostSnapshot (the bridge) alive** — so the UI keeps fetching data while the world is frozen. Built on the existing `TickRate::Paused` + the `input.rs` paused-allowlist seam, triggered by `HudGroups::gameplay_occluded` (D-170). For static Layer-1 geography pause is only a compute-saver; for *dynamic* state (economics) a stable frozen snapshot is essential.
**(2) Data — layer-stream proxy (D-225).** Streams `Layer1Output` (later economics state, save state) from server to client on demand, mod-first, no bake.
**(3) Human-visual viewer (#960).** The Atlas renders cascade layers as **additive, toggleable overlays** (relief base + rivers + drainage basins + attractors; *shape* encodes attractor type, *color* encodes sub-biome), extending the existing `OVERLAY_DEFS` / `AtlasOverlayBar` with a `generation` overlay group + a left-side legend panel. Buttons start `pending`/locked and unlock as each layer's data arrives ("grows as each layer lands", D-166).
**(4) Agent-navigable channel.** A client-side `AtlasAgentInterface` exposing JSON `observe` (current data state + a walkable **UI affordance tree**) and `act` (named **semantic intents** — `select_body`, `open_regional`, `set_overlay`, `back`, … — backed by the same handlers a click calls, **not** pixel coordinates; the map uses `_gui_input`). Runs headless. Turns human-eyeball review into an agent-automatable QA sweep across the whole Reach (and later economics/saves).
**(5) Capture — reuse, don't reinvent.** Interactive screenshots reuse the **existing `tests/run-visual` capture primitive** (frame-delayed `save_png`, proven by `character_creation.gd`'s standalone capture). The agent channel adds *interactive* explore-and-capture; the existing scenario/flow **visual-golden regression** role stays distinct and unchanged. Clear hover state before capture.
**Consumers** (build geography now; the others adopt the pattern in their own phases — do **not** build them now): Layer-1 geography (Phase 4) → economics (dynamic; pause essential) → save-game inspection (load → pause → inspect; `save_state.rs` substrate).
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
- **Implementation:** Phase 4 (epic #750), built bottom-up — auto-pause substrate, #969 proxy (D-225), #960 viewer, agent channel, agent capture. Geography is the first consumer.
- **Raised by:** Jeroen + Claude (design), with Tyre (channel/pause/headless architecture) + Araminta (overlay encoding + affordance UX), 2026-05-24.
- **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer)
- **Dissent:** None
---
*86 decisions (D-001 through D-226, excluding gaps). Last updated: 2026-05-24 (D-226 — live-pause inspection harness)*
+11 -2
View File
@@ -331,11 +331,20 @@ Technical foundation questions: engine, protocols, data structures, performance,
---
### Q-098: Persistence of generated river/city mapping outputs
- **Status:** Open — to resolve during Phase 4 execution-model work (#952)
- **Status:** Resolved — D-225 (atlas layer-stream proxy), 2026-05-23
- **Resolution:** Resolved against the question's own premise. The viewer does **not** need a durable store under the LRU: the cascade is deterministic and ~45 ms, so eviction → recompute is acceptable. Baking into `systems.db` (option a) is rejected — it bloats the install and makes modded bodies second-class. Decision: **lazy compute on demand**, served by a mod-first **layer-stream proxy** that resolves a body's source files (base + mod dirs) and streams the computed `Layer1Output` over the existing IPC bridge, backed by the D-203 in-memory LRU (miss → background `AnalyzeBody`; eviction → recompute). See [D-225](architecture.md#d-225). The mod-content-catalog corner (mods adding *new* body rows / `terrain_reference` to the binary `systems.db`) is spun off to Q-099.
- **Question:** The deterministic cascade can recompute river courses (D8 drainage, D-208) and city placements (economic sim + attractor matching, D-211) from seed at any time, so persisting them is a *cost* optimization, not a correctness need. But the compute is expensive — recomputing per session or per atlas view is waste. How are these mapping outputs persisted so they are computed **once per body and kept**? D-203's BodyWorldState cache is an LRU — it *evicts* (volatile). The fork: **(a)** build-time bake into `systems.db` (D-200 build-time tier — precompute all, ship); **(b)** lazy compute + persist at runtime (cache DB / savegame — compute on first visit, keep); **(c)** hybrid. Whatever the answer, the in-memory LRU should sit over a *durable* store so eviction triggers a cheap reload, not a recompute.
- **Context:** Raised 2026-05-22 looking ahead from the Phase 4 markers strip (#951). Refines D-200 (three-tier execution) and D-203 (LRU cache). Gates the Atlas layer viewer (#960), which needs persisted mapping to render without recomputing. Determinism (#952) guarantees recompute is always a valid fallback.
- **Cross-reference:** [D-200](architecture.md#d-200), [D-203](architecture.md#d-203), [D-208](architecture.md#d-208), [D-211](architecture.md#d-211), #952 (determinism harness), #960 (atlas viewer)
---
*47 questions (8 resolved, 1 partially resolved, 38 open). Last updated: 2026-05-22.*
### Q-099: Mod content catalog — body rows / terrain_reference overlay for systems.db
- **Status:** Open — spun off from D-225 (2026-05-23)
- **Question:** D-225 resolves mod *file* resolution (a mod body's source `heightmap.png` is found by searching mod dirs over the base install). But a mod adding a *new* body also needs that body discoverable: the `bodies` row and its `terrain_reference` live in `systems.db`, which is binary and source-canonical (D-189) — mods cannot append to it. How does a mod register new bodies (and other DB-resident catalog rows)? Options: a mod manifest the server merges into an in-memory catalog overlay at load; a parallel mod catalog DB layered over the `systems.db` reads; or a documented mod build step. Out of scope for #960 (base-install resolution ships the viewer); needed before third-party bodies are first-class.
- **Context:** Raised 2026-05-23 from the D-225 mod-first layer-stream proxy design. The proxy makes first-party and mod bodies flow through an identical resolve→compute→stream path *given a resolvable source file*; this question is the remaining gap — getting a mod's new body into the catalog the resolver consults.
- **Cross-reference:** [D-225](architecture.md#d-225), [D-189](architecture.md#d-189) (systems.db source-canonical), #960 (atlas viewer — base-install only for now)
---
*48 questions (9 resolved, 1 partially resolved, 38 open). Last updated: 2026-05-23.*
+24
View File
@@ -17,6 +17,7 @@
use std::path::Path;
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::seed::SeedChain;
@@ -48,6 +49,29 @@ pub struct CascadeSnapshot {
pub layer1: Option<Layer1Output>,
}
impl CascadeSnapshot {
/// Convert into a [`BodyWorldState`] for the D-203 cache (#968). Moves the
/// heightmap raster and the Layer-1 outputs in; `last_accessed` starts at 0
/// (the cache stamps it on read). A snapshot that stopped at Layer 0 yields
/// empty river/basin/attractor data.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors) = match self.layer1 {
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
};
BodyWorldState {
body_id: self.body_id,
heightmap: self.heightmap.data,
heightmap_width: self.heightmap.width,
heightmap_height: self.heightmap.height,
river_network,
drainage_basins,
attractors,
last_accessed: 0,
}
}
}
/// Run the cascade from a heightmap already in memory, up to `up_to`.
///
/// Pure (no I/O); this is the testable core. `body_seed` is this body's
+94 -44
View File
@@ -21,11 +21,17 @@
//!
//! **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::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::seed::SeedChain;
// ---------------------------------------------------------------------------
// Priority
// ---------------------------------------------------------------------------
@@ -50,8 +56,16 @@ pub enum GenPriority {
/// A unit of background generation work (D-206).
#[derive(Debug, Clone)]
pub enum GenWorkItem {
/// Run D8 drainage analysis + attractor extraction for this body.
AnalyzeBody { body_id: String },
/// 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,
},
/// Generate a Phase 1 DistrictSkeleton for this city.
GenerateSkeleton { city_id: u64 },
/// Pre-fill a chunk in an existing district.
@@ -63,7 +77,7 @@ pub enum GenWorkItem {
impl GenWorkItem {
pub fn body_id(&self) -> Option<&str> {
if let GenWorkItem::AnalyzeBody { body_id } = self {
if let GenWorkItem::AnalyzeBody { body_id, .. } = self {
Some(body_id)
} else {
None
@@ -80,6 +94,8 @@ impl GenWorkItem {
pub enum GenCompletion {
BodyAnalyzed {
body_id: String,
/// The computed world state, ready for `BodyWorldStateCache::insert`.
state: BodyWorldState,
},
SkeletonGenerated {
city_id: u64,
@@ -286,15 +302,38 @@ impl Default for GenerationQueue {
// Work execution stub
// ---------------------------------------------------------------------------
/// Execute one work item. This is the Rayon task body.
/// Execute one work item. This is the Rayon task body (off the tick thread).
///
/// Currently a stub — real implementations will call `drainage::analyze()`,
/// the attractor pipeline, and the district skeleton generator. Stubs return
/// immediate success to allow the queue infrastructure to be tested independently.
/// `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 } => GenCompletion::BodyAnalyzed {
body_id: body_id.clone(),
GenWorkItem::AnalyzeBody {
body_id,
heightmap_path,
sea_level,
body_seed,
} => 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
};
let snapshot =
run_cascade_from_heightmap(*body_seed, working, CascadeLayer::Topography);
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 } => {
GenCompletion::SkeletonGenerated { city_id: *city_id }
@@ -322,22 +361,51 @@ mod tests {
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),
}
}
#[test]
fn submit_and_drain() {
let q = make_queue();
q.submit(
GenWorkItem::AnalyzeBody {
body_id: "TestBody".to_string(),
},
GenPriority::Medium,
);
// Give Rayon time to complete the (stub) task.
std::thread::sleep(Duration::from_millis(50));
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 } if body_id == "TestBody"
GenCompletion::BodyAnalyzed { body_id, state }
if body_id == "TestBody"
&& state.heightmap_width == 32
&& state.heightmap_height == 16
));
}
@@ -345,18 +413,8 @@ mod tests {
fn dedup_analyze_body() {
let q = make_queue();
// Submit the same body twice before it can complete.
q.submit(
GenWorkItem::AnalyzeBody {
body_id: "Dup".to_string(),
},
GenPriority::Low,
);
q.submit(
GenWorkItem::AnalyzeBody {
body_id: "Dup".to_string(),
},
GenPriority::Low,
);
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.
@@ -399,18 +457,8 @@ mod tests {
// 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(
GenWorkItem::AnalyzeBody {
body_id: "BodyA".to_string(),
},
GenPriority::Low,
);
q.submit(
GenWorkItem::AnalyzeBody {
body_id: "BodyB".to_string(),
},
GenPriority::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).
@@ -421,9 +469,11 @@ mod tests {
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")
matches!(&first[0], GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "BodyA")
);
assert!(
matches!(&second[0], GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "BodyB")
);
}
+3
View File
@@ -13,6 +13,9 @@ pub mod features;
pub mod gen_queue;
pub mod heightmap;
pub mod layer1;
pub mod plugin;
pub mod skeleton_gen;
pub mod subbiome;
pub mod tile_condition;
pub use plugin::GenerationPlugin;
+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"
);
}
}
+2
View File
@@ -154,6 +154,7 @@ fn main() {
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default());
app.add_plugins(settled_reach_server::atlas::GenerationPlugin);
// Initialize culture resolver (#679, D-128).
// systems.db is shipped read-only alongside the binary.
@@ -391,6 +392,7 @@ fn dump_schedule_graph() {
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default());
app.add_plugins(settled_reach_server::atlas::GenerationPlugin);
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(0));
// Access Schedules resource directly — schedules are populated by plugins