Files
settled-reach/server/src/atlas/body_world_state.rs
T
jpmschweitzerandClaude Opus 4.8 def70eaf37 feat(simulation): D-239 three-carrier foundation (T-1023/1024/1026)
First foundation slice of the Atlas-to-tile derivation model (epic T-974),
building the carrier layer ahead of its T-1027+ consumers.

T-1026 — Anti-squaring domain warp (D-239 §4): stateless pure
fn(seed,body_id,pos)->(f64,f64), ±8m, f64 to the final voxel then as-i32
truncation for IEEE-754 cross-target determinism. New domain_warp.rs,
SeedDomain::DomainWarp; golden-vector + cross-thread tests. Position math
only — D-010 integer discipline preserved downstream. Marked dead_code
until the T-1028 VoxelColumn pipeline consumes it.

T-1023 — RegionProfile carrier (D-239 §1,§10): new RegionProfile +
TectonicClass/GlaciationGrade/PrecipitationClass enums + BodyParams; derived
per-region river_threshold replacing the global 200 for tile consumers.
regions: BTreeMap on BodyWorldState, populated via the cascade's new
RegionProfile layer (runs when body_params is Some, else falls back to
Settlement). D-010 integer discipline, BTree ordering.

T-1024 — District climate primitives (D-239 §2): nullable temperature_c +
moisture on RegionProfile, mean-annual scalar (no clock dep; dynamic branch
deferred to Q-105). Hybrid inputs — new bodies.axial_tilt_deg column imported
from planet-gen body-defs (populate_axial_tilt_deg, 2611 bodies), luminosity
and orbital distance derived at runtime; greenhouse + diurnal-swing tables in
source-canonical climate_constants.toml. D-239 implementation note added.

cargo test: 1498 passed, 0 failed. clippy clean (pre-existing
large_enum_variant only). make check-systems-db: stamp fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:28:31 +02:00

292 lines
11 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::region_profile::{RegionPos, RegionProfile};
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>,
/// 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-region (~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 `RegionPos` for D-010 determinism.
/// Empty until the RegionProfile layer has run.
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![],
quarters: 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());
}
}