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>
411 lines
18 KiB
Rust
411 lines
18 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::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());
|
||
}
|
||
}
|