Files
settled-reach/server/src/atlas/body_world_state.rs
T
jpmschweitzerandClaude Fable 5 4f52abd532 feat(simulation): wire region climate layer into the cascade + proxy (T-1113, D-243)
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>
2026-07-16 18:51:43 +02:00

310 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}