//! BodyWorldState — per-body Layer 1–2 cache (D-203). //! //! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation //! data for up to 50 planetary bodies. Populated by the runtime-background //! tier (D-206) via Rayon tasks; read by the main tick thread without blocking. //! //! Eviction policy: LRU — the body with the oldest `last_accessed` tick is //! evicted on overflow, unless it is pinned (current player location or an //! adjacent-system neighbor). use std::collections::{BTreeMap, BTreeSet}; use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::district_profile::DistrictProfile; use crate::atlas::region_profile::RegionProfile; use crate::atlas::road_graph::RoadGraph; use crate::atlas::scale::{RegionPos, SurveyCellPos}; use crate::simulation::generator::{ GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus, }; /// Simulation tick counter — monotonically increasing u64. pub type SimTick = u64; /// Maximum number of bodies the cache holds before evicting the LRU entry. pub const CACHE_CAPACITY: usize = 50; // --------------------------------------------------------------------------- // Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side) // --------------------------------------------------------------------------- /// River network extracted by the D8 drainage algorithm (D-208). /// Stub — replaced when #918 is implemented. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RiverNetwork { /// Pixel positions (row, col) of all river cells (flow_accumulation > 200). pub river_cells: Vec<(u16, u16)>, /// Positions where two or more rivers merge. pub confluences: Vec<(u16, u16)>, /// Positions where rivers reach sea level or the heightmap edge. pub mouths: Vec<(u16, u16)>, /// Quantized river class per entry of `river_cells` (same index, same /// length) — 0=stream, 1=tributary, 2=trunk (T-1156 wave 1). Deterministic /// per body+seed (D-010/D-208): a monotonic function of each cell's flow /// accumulation, binned by `drainage::classify_river_cell`. This is the /// carrier for client-side per-rung filtering (Tyre's binding ruling — no /// new wire field beyond this array; ladder rungs decide which classes to /// draw by filtering this list, not by a server-side windowed query). /// `#[serde(default)]` so pre-T-1156 payloads/consumers (and any golden /// fixture predating this field) still decode — an absent array becomes /// empty, never a decode error (the additive T-1124 §1 pattern). #[serde(default)] pub river_class: Vec, /// Per-`river_cells`-entry D8 downstream pointer (T-1170 Ruling 2a) — same /// index, same length as `river_cells`/`river_class`. Each river cell has /// exactly one downstream direction, so this is the exact same shape as /// `river_class`, captured in `extract_river_network` where `fdir[i]` is /// already in scope (one `.map()`, no new grid pass). /// /// **Values 0–7:** an index into `drainage::D8` — the downstream neighbor /// direction, i.e. "this river cell flows toward `D8[value]`". /// /// **Sentinel values above 7 (Ruling 2c):** /// - [`RIVER_DOWNSTREAM_MOUTH`] — flow reaches a raw-sea cell (the river's /// mouth in the D8 sense; T-1170's course inventor walks stations from /// here to the invented-coast terminus, Ruling 3e). /// - [`RIVER_DOWNSTREAM_EDGE_DRAIN`] — flow exits the grid's top/bottom /// edge. This is a **grid artifact, not a mouth** (Ruling 3f) — pole-edge /// exits are deliberately excluded from `mouths` at extraction (see /// `extract_river_network`'s doc). /// - [`RIVER_DOWNSTREAM_TERMINAL`] — **reserved, unused in round 1.** Flow /// ends in an interior sink (future endorheic basin / inland delta, /// Ruling 7b). Reserving the value now keeps that future path additive /// (no wire migration) rather than requiring a new sentinel later. /// /// Why persist rather than reconstruct from adjacency alone (Ruling 2b): /// at a 3-river-neighbor confluence, adjacency cannot distinguish inflow /// from outflow, and re-deriving direction from elevation is re-running D8 /// badly — the true answer (`fdir[i]`) is already computed and in scope at /// extraction time; discarding it and re-deriving later is strictly worse /// than keeping the ~1 byte/river-cell projection. The former D-203 /// memory-frugality rationale for discarding covered the 131 KB *full* /// `fdir` grid, not this per-river-cell projection. /// /// `#[serde(default)]` — same additive pattern as `river_class`: an absent /// array (pre-T-1170 payload/fixture) decodes to empty, never an error. #[serde(default)] pub river_downstream: Vec, /// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197 /// review, Hoshe #1) — same index/length shape as `river_class`/ /// `river_downstream`. **Only meaningful where `river_downstream[i] == /// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or /// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read. /// /// **Why this exists — the second instance of the Ruling-2b anti-pattern.** /// `extract_river_network` already computes the real sub-sea-level /// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation /// check that flips `river_downstream[i]` to /// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in /// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers /// via `river_downstream` itself. Discarding `(nr, nc)` a second time /// left `river_course::build_edges` with nothing to build a real chord /// from for Mouth edges: it filled `downstream = upstream` as a /// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips /// `invent_course`'s single-point degenerate return, which makes /// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course /// never enters the `pts.len() >= 2` probe branch either) — every Mouth /// edge silently resolved `CourseTerminus::None` instead of `Mouth`. /// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are /// a small fraction of river cells, not a new grid pass) is what makes /// `build_edges` give Mouth edges a real ~one-cell chord toward the raw /// sea, so the termination walk + bisect + one-segment D8-probe fallback /// (Ruling 3e) are actually reachable. /// /// `#[serde(default)]` — same additive pattern as `river_class`/ /// `river_downstream`. #[serde(default)] pub river_seaward: Vec<(u16, u16)>, } /// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow /// reaches a raw-sea cell (T-1170 Ruling 2c). Values 0–7 are real D8 direction /// indices, so sentinels start at 8. pub const RIVER_DOWNSTREAM_MOUTH: u8 = 8; /// [`RiverNetwork::river_downstream`] sentinel: this river cell's flow exits /// the grid's top/bottom (polar) edge — a grid artifact, never a mouth /// (T-1170 Ruling 2c/3f). pub const RIVER_DOWNSTREAM_EDGE_DRAIN: u8 = 9; /// [`RiverNetwork::river_downstream`] sentinel: **reserved, unused in round /// 1.** Flow terminates in an interior sink (future endorheic basin / inland /// delta, T-1170 Ruling 2c/7b). Reserving this value now is what makes that /// future extension additive rather than a wire migration. pub const RIVER_DOWNSTREAM_TERMINAL: u8 = 10; /// One drainage basin / province derived from watershed analysis (D-205). /// Stub — boundary polyline data comes from atlas_province_boundaries. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DrainageBasin { pub basin_id: u32, /// Boundary polyline as pixel-space (row, col) points. pub boundary: Vec<(u16, u16)>, /// Fraction of the body's surface area in this basin. pub area_pct: f32, /// Territory control status (D-212, #956). Set by the cascade after Layer 1 /// from the body's `dominant_faction`. Uniform across a body's basins for now /// (a single system-level faction); the per-basin field is forward-compatible /// for when per-province faction data exists. Defaults to `FrontierUnclaimed` /// at drainage construction; the cascade overwrites it. pub territorial_status: TerritorialStatus, } // --------------------------------------------------------------------------- // BodyWorldState // --------------------------------------------------------------------------- /// Pre-computed Layer 1–2 generation data for one planetary body. /// /// Produced by the runtime-background tier and stored in `BodyWorldStateCache`. /// The main tick thread reads this data without performing any DB or CPU work. #[derive(Debug, Clone)] pub struct BodyWorldState { pub body_id: String, /// Downsampled working elevation grid (float32, row-major). /// Full-resolution data lives in atlas_body_heightmaps; this is reduced /// for the ~8KB working-resolution budget described in D-203. pub heightmap: Vec, pub heightmap_width: u32, pub heightmap_height: u32, /// Elevation fraction below which terrain is ocean/sea (T-1174/D-256) — /// carried alongside the working-grid data above so a `BodyHeightmap` can /// be reconstructed in-memory (no disk re-read) wherever a `TerrainAnalysis` /// needs re-deriving from this cached body (e.g. /// `TerrainAnalysisCache::get_or_derive` for an exact-position skeleton /// judgment, D-256(d)). Sourced from `CascadeSnapshot.heightmap.sea_level`. pub sea_level: f32, /// D8 drainage analysis output (D-208). Empty until drainage task completes. pub river_network: RiverNetwork, /// Drainage basins from watershed analysis (D-205). pub drainage_basins: Vec, /// Geographic attractors (D-195, D-209). Empty until attractor task completes. pub attractors: Vec, /// Settlement placements (D-211, #955). Attractor-matched city positions. /// Empty until the Layer-3 placement task completes. pub placements: Vec, /// Inter-settlement road/rail graph (D-211, T-1038). Session-cached, never /// serialized — re-derived from `(placements, terrain, seed)` on resume. /// Empty until the Layer-2 road task completes (needs placements + terrain). pub road_graph: RoadGraph, /// Quarter-level world state, keyed by `QuarterId` (D-230). /// /// Populated by `GenCompletion::SkeletonGenerated` after the plan phase /// completes for each city. `BTreeMap` for D-010 determinism. pub quarters: BTreeMap, /// Per-survey-cell profiles derived from body params + terrain (T-1023, /// D-239 §1) — the D-256(b) coarse planning raster, NOT the true D-243 /// district grid (see [`SurveyCellPos`]). /// /// Populated by the background cascade after Layer 1 completes. /// `BTreeMap` keyed by `SurveyCellPos` for D-010 determinism. /// Empty until the DistrictProfile layer has run. pub districts: BTreeMap, /// Per-region (~205 km) climate context — season/weather/temperature /// baseline cells (D-243 §3, T-1113). /// /// Populated by the background cascade's Region layer; the covering grid /// only (no blend-padding ring — see `cascade::LayerRegionOutput`). /// `BTreeMap` keyed by `RegionPos` for D-010 determinism. Empty until the /// Region layer has run. Footprint is trivial (~195×98 cells at the D-243 /// true-scale ceiling; a handful on today's working grids). pub regions: BTreeMap, /// Last sim tick this entry was read. Used for LRU eviction. pub last_accessed: SimTick, } // --------------------------------------------------------------------------- // BodyWorldStateCache — Bevy Resource // --------------------------------------------------------------------------- /// Bevy `Resource` holding the LRU cache of per-body world state (D-203). /// /// Initialized empty at server startup. Entries are inserted by the /// background generation queue (D-206) and read by main-thread systems. /// /// All mutations go through the provided methods to maintain the /// invariant that `entries.len() <= capacity`. #[derive(Resource, Debug, Default)] pub struct BodyWorldStateCache { entries: BTreeMap, /// Body IDs that must not be evicted regardless of `last_accessed`. pinned: BTreeSet, capacity: usize, } impl BodyWorldStateCache { pub fn new(capacity: usize) -> Self { Self { entries: BTreeMap::new(), pinned: BTreeSet::new(), capacity, } } /// Insert or replace a `BodyWorldState` entry. /// /// If the cache is at capacity, evicts the LRU unpinned entry before /// inserting. If all entries are pinned and the cache is full, the new /// entry is inserted anyway (capacity is a soft limit against unbounded /// growth, not a hard reject). pub fn insert(&mut self, state: BodyWorldState) { if self.entries.len() >= self.capacity && !self.entries.contains_key(&state.body_id) { self.evict_lru(); } self.entries.insert(state.body_id.clone(), state); } /// Get a reference to the state for `body_id`, bumping `last_accessed`. pub fn get(&mut self, body_id: &str, current_tick: SimTick) -> Option<&BodyWorldState> { if let Some(entry) = self.entries.get_mut(body_id) { entry.last_accessed = current_tick; } self.entries.get(body_id) } /// Get a reference without bumping `last_accessed` (read-only path). pub fn peek(&self, body_id: &str) -> Option<&BodyWorldState> { self.entries.get(body_id) } /// Get a mutable reference without bumping `last_accessed`. /// /// Used by the district-state insertion path (D-230) which writes into /// the cached state without constituting a "read" for LRU purposes. pub fn peek_mut(&mut self, body_id: &str) -> Option<&mut BodyWorldState> { self.entries.get_mut(body_id) } /// Returns `true` if the cache has an entry for `body_id`. pub fn contains(&self, body_id: &str) -> bool { self.entries.contains_key(body_id) } /// Pin `body_id` — exempt from LRU eviction. pub fn pin(&mut self, body_id: &str) { self.pinned.insert(body_id.to_string()); } /// Unpin `body_id` — allow eviction again. pub fn unpin(&mut self, body_id: &str) { self.pinned.remove(body_id); } /// Number of entries currently in the cache. pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } fn evict_lru(&mut self) { // Find the unpinned entry with the smallest last_accessed tick. let victim = self .entries .iter() .filter(|(id, _)| !self.pinned.contains(*id)) .min_by_key(|(_, s)| s.last_accessed) .map(|(id, _)| id.clone()); if let Some(id) = victim { self.entries.remove(&id); } } } #[cfg(test)] mod tests { use super::*; #[allow(unused_imports)] use crate::simulation::generator::GeographicAttractor; fn make_state(body_id: &str, tick: SimTick) -> BodyWorldState { BodyWorldState { body_id: body_id.to_string(), heightmap: vec![0.5; 16], heightmap_width: 4, heightmap_height: 4, sea_level: 0.3, river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], placements: vec![], road_graph: RoadGraph::default(), quarters: BTreeMap::new(), districts: BTreeMap::new(), regions: BTreeMap::new(), last_accessed: tick, } } #[test] fn insert_and_get() { let mut cache = BodyWorldStateCache::new(50); cache.insert(make_state("Alpha", 1)); assert!(cache.contains("Alpha")); assert!(!cache.contains("Beta")); let entry = cache.get("Alpha", 5).unwrap(); assert_eq!(entry.body_id, "Alpha"); assert_eq!(entry.last_accessed, 5); } #[test] fn evicts_lru_on_overflow() { let mut cache = BodyWorldStateCache::new(3); cache.insert(make_state("A", 10)); cache.insert(make_state("B", 20)); cache.insert(make_state("C", 30)); // Cache is full; inserting D should evict A (oldest tick = 10). cache.insert(make_state("D", 40)); assert_eq!(cache.len(), 3); assert!(!cache.contains("A"), "A should have been evicted"); assert!(cache.contains("B")); assert!(cache.contains("C")); assert!(cache.contains("D")); } #[test] fn pinned_body_not_evicted() { let mut cache = BodyWorldStateCache::new(3); cache.insert(make_state("A", 10)); cache.insert(make_state("B", 20)); cache.insert(make_state("C", 30)); // Pin A so it cannot be evicted. cache.pin("A"); // Inserting D must evict B (oldest unpinned). cache.insert(make_state("D", 40)); assert!(cache.contains("A"), "pinned A must not be evicted"); assert!(!cache.contains("B"), "B should have been evicted instead"); } #[test] fn update_last_accessed_on_get() { let mut cache = BodyWorldStateCache::new(3); cache.insert(make_state("A", 1)); cache.insert(make_state("B", 2)); cache.insert(make_state("C", 3)); // Cache is full. Get A at tick 100 — bumps its last_accessed above C and B. cache.get("A", 100); // Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100). cache.insert(make_state("D", 4)); assert!( cache.contains("A"), "A was recently accessed — must survive" ); assert!( !cache.contains("B"), "B had oldest access time — should be evicted" ); } #[test] fn default_capacity_is_zero() { // Default resource starts empty. let cache = BodyWorldStateCache::default(); assert!(cache.is_empty()); } }