Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
256 lines
9.0 KiB
Rust
256 lines
9.0 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 crate::simulation::generator::GeographicAttractor;
|
||
|
||
/// 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)]
|
||
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)]
|
||
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,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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>,
|
||
/// 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)
|
||
}
|
||
|
||
/// 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![],
|
||
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());
|
||
}
|
||
}
|