Compare commits
3
Commits
5c4a3fda19
...
bd5355d0a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd5355d0a5 | ||
|
|
8cedf670f0 | ||
|
|
53263492ab |
@@ -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)*
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user