Files
settled-reach/server/src/atlas/body_world_state.rs
T
jpmschweitzerandClaude Fable 5 933e1f4ee4 fix(simulation): one absolute-metre derive core (D-256, T-1174)
derive_district_profile is now a thin wrapper over the shared
derive_at_metres_with_riparian core at survey-cell-centre world metres
— one derive core, two position sets. The batch pseudo-grid and the
true D-243 district grid were two coordinate systems sharing one bare
(i32,i32) type; the new SurveyCellPos newtype re-keys every batch
product (BodyWorldState.districts, Layer1Output.survey_basin_dirs) so
the compiler rejects cross-namespace passing.

Fixes two latent same-position divergences the T-1174 investigation
surfaced: three inconsistent latitude conventions collapse into the
core's single inverse mapping, and the region-climate baseline now
floor-divides true world metres instead of collapsing the whole body
onto region (0,0)'s baseline — batch climate becomes latitude/region
graded (D-245 direction: every changed believability metric increased).

Binding preservations per D-256(c): basin_direction rides a post-call
override with the true L1 D8 survey-cell aggregate (layer1's map
re-keyed to SurveyCellPos, identity lookup — a floor-divide lookup
against the pseudo-keyed map would have silently defaulted every cell
North); the riparian verdict comes from near_perennial_water_at, never
the empty-slice default (which would have flipped riverside
vegetation_class).

Quarter-skeleton morphology_zone now resolves at the settlement's
exact world position via derive_at_metres at work-item execution
(where TerrainAnalysisCache lives), replacing the survey-cell-centre
map lookup (D-256(d)); settlement_district_pos fixed to true-district
floor-division in passing (same doc/impl mismatch class). Second
pixel-vs-metre conflation fixed in aliveness_probe's anchor-walk math.

Window path byte-unchanged (window_derivation_golden 6/6 byte-
identical); derivation_harness golden untouched; believability golden
regenerated. Full lib + integration suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:40:13 +02:00

411 lines
18 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::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<u8>,
/// 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<u8>,
/// 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<f32>,
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<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-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<SurveyCellPos, 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,
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());
}
}