CascadeLayer::Region appended per the enum's append-only Ord rule (depends only on seed/body_params/heightmap dims — documented); BodyWorldState.regions BTreeMap per the districts precedent; RegionGridLayer dense row-major all-integer encoding (season/weather repr(u8) discriminants, mean_temp deci-degC i16 with i16::MIN airless sentinel, moisture_q u8); build_region_grid mirrors build_district_grid; protocol.gd region_grid passthrough (visual overlay deliberately out of scope); wire fixtures regenerated via make fixtures. atlas:: suite 507 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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::{DistrictPos, DistrictProfile};
|
||
use crate::atlas::region_profile::RegionProfile;
|
||
use crate::atlas::road_graph::RoadGraph;
|
||
use crate::atlas::scale::RegionPos;
|
||
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)>,
|
||
}
|
||
|
||
/// 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<f32>,
|
||
pub heightmap_width: u32,
|
||
pub heightmap_height: u32,
|
||
/// 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<DrainageBasin>,
|
||
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
|
||
pub attractors: Vec<GeographicAttractor>,
|
||
/// Settlement placements (D-211, #955). Attractor-matched city positions.
|
||
/// Empty until the Layer-3 placement task completes.
|
||
pub placements: Vec<CityPlacement>,
|
||
/// 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<QuarterId, QuarterWorldState>,
|
||
/// Per-district (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1).
|
||
///
|
||
/// Populated by the background cascade after Layer 1 completes.
|
||
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
|
||
/// Empty until the DistrictProfile layer has run.
|
||
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
|
||
/// 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<RegionPos, RegionProfile>,
|
||
/// 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<String, BodyWorldState>,
|
||
/// Body IDs that must not be evicted regardless of `last_accessed`.
|
||
pinned: BTreeSet<String>,
|
||
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,
|
||
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());
|
||
}
|
||
}
|