6036 lines
258 KiB
Rust
6036 lines
258 KiB
Rust
//! Atlas layer-stream proxy handler (#969, D-225).
|
||
//!
|
||
//! Serves a body's generation-cascade layer data to the client, compute-on-
|
||
//! demand and mod-first:
|
||
//! - **Cache hit** → serialize the cached `Layer1Output` and reply `Ready`.
|
||
//! - **Cache miss** → resolve the body's source heightmap ([`BodySourceResolver`]),
|
||
//! enqueue an `Immediate` `AnalyzeBody` on the background queue (#968), and
|
||
//! reply `Pending` (the client re-requests; the drain system populates the
|
||
//! cache, so a later request hits).
|
||
//!
|
||
//! Pure handler logic; the bridge wiring (message routing) is the proxy's other
|
||
//! half. No baking — the heightmap is the only source of truth (D-225).
|
||
|
||
use bevy_ecs::prelude::Resource;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::atlas::body_params_reader::BodyParamsReader;
|
||
use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, RiverNetwork, SimTick};
|
||
use crate::atlas::cascade::CascadeLayer;
|
||
use crate::atlas::city_context_reader::CityContextReader;
|
||
use crate::atlas::district_profile::{BodyParams, DistrictPos};
|
||
use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue};
|
||
use crate::atlas::layer1::Layer1Output;
|
||
use crate::atlas::river_course::{self, EdgeTerminusKind, InventedCourse};
|
||
use crate::atlas::road_graph::RoadNodeKind;
|
||
use crate::atlas::scale::DISTRICT_M;
|
||
use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError};
|
||
use crate::bridge::ConnectionId;
|
||
use crate::seed::{SeedChain, SeedDomain};
|
||
use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAuthority, ZoningType};
|
||
|
||
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
|
||
/// (the loader prefers the chunk; this is only the floor).
|
||
pub(crate) const DEFAULT_SEA_LEVEL: f32 = 0.3;
|
||
|
||
/// Hard server-side clamp on [`AtlasLayerRequest::window_n`] (D-226 T-1124
|
||
/// amendment §4, binding numbers). 64×64 districts ≈ 131 km per side — the
|
||
/// same window size `aliveness_probe --render`'s default already proved out
|
||
/// server-side (T-1123). **Never trust `window_n` from the wire** — every
|
||
/// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving.
|
||
///
|
||
/// **Finer-than-district / district rungs ONLY (T-1152).** This ceiling was
|
||
/// calibrated for district spacing; applying it unchanged to `Region`
|
||
/// requests would be nonsensical — see [`DISTRICT_WINDOW_MAX_N_REGION`]'s doc
|
||
/// for why `Region` needs its own, much larger per-axis ceiling on the SAME
|
||
/// `n` (window extent in districts).
|
||
pub const DISTRICT_WINDOW_MAX_N: u32 = 64;
|
||
|
||
/// Per-axis ceiling on [`AtlasLayerRequest::window_n`] for
|
||
/// [`WindowGranularity::Region`] requests ONLY (T-1152 step 4: "work out what
|
||
/// n means at region granularity ... and document it"). `n` is always the
|
||
/// window extent in DISTRICTS regardless of rung (T-1150 design doc §2,
|
||
/// unchanged) — but [`DISTRICT_WINDOW_MAX_N`] (64 districts ≈ 131 km) was
|
||
/// sized for the district rung's own per-cell world extent, and reusing it
|
||
/// unchanged for `Region` would clamp every region window to well under
|
||
/// ONE region's own 100-district side ([`crate::atlas::scale::DISTRICTS_PER_REGION`]),
|
||
/// making [`WindowGranularity::cell_grid_side`] round every legal `n` down
|
||
/// to a degenerate 1×1 cell grid — a "region view" that can never show more
|
||
/// than one region cell is not a useful rung.
|
||
///
|
||
/// Derived (not a new magic number, per the D-243 instruction): the largest
|
||
/// `n` for which `cell_grid_side(n) == sqrt(WIRE_CAP_CELLS)` (64 region
|
||
/// cells across, the same [`WIRE_CAP_CELLS`]-derived side length district
|
||
/// mode already reaches at its own cap) is
|
||
/// `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100 = 6,400`
|
||
/// districts (≈13,100 km — comfortably covering a planetary hemisphere's
|
||
/// worth of region tiles in one capped request). The WIRE-SIZE ceiling
|
||
/// ([`WIRE_CAP_CELLS`] via [`clamp_window_n_v2`]) is still the actual
|
||
/// enforcement point (never trusted from the wire) — this constant only
|
||
/// widens the PER-AXIS ceiling far enough that the wire-size math has room
|
||
/// to matter for `Region`, exactly mirroring how [`DISTRICT_WINDOW_MAX_N`]
|
||
/// relates to the wire-size ceiling at district granularity (see
|
||
/// `clamp_window_n`'s doc: "Applied AFTER the per-axis clamp so a request
|
||
/// that already satisfies [the per-axis cap] still shrinks further").
|
||
pub const DISTRICT_WINDOW_MAX_N_REGION: u32 =
|
||
WIRE_CAP_CELLS_SQRT * crate::atlas::scale::DISTRICTS_PER_REGION as u32;
|
||
|
||
/// `WIRE_CAP_CELLS`'s integer square root (64) — computed once as a `const`
|
||
/// so [`DISTRICT_WINDOW_MAX_N_REGION`]'s derivation is checkable at compile
|
||
/// time rather than repeating the literal `64` as an uncommented magic
|
||
/// number. `WIRE_CAP_CELLS = 4_096 = 64²` exactly (see that constant's own
|
||
/// doc), so this is exact integer arithmetic, not an approximation.
|
||
const WIRE_CAP_CELLS_SQRT: u32 = 64;
|
||
const _: () = assert!(WIRE_CAP_CELLS_SQRT * WIRE_CAP_CELLS_SQRT == WIRE_CAP_CELLS);
|
||
|
||
/// Finer-than-district spacing multipliers (T-1150, zoom ladder design doc
|
||
/// §3/§5): the number of derived cells per district side. `1` = district
|
||
/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior).
|
||
/// `4` = quarter spacing (512 m/cell, D-243) — Option B from the design doc:
|
||
/// full reclassification at the finer spacing via `derive_at_metres`, not a
|
||
/// coarser-cell interpolation.
|
||
///
|
||
/// **T-1159:** these used to also be legal VALUES of the wire-facing
|
||
/// `AtlasLayerRequest::window_granularity: u32` field (resolved via the
|
||
/// now-removed `resolve_window_granularity`) — that field is retired, fully
|
||
/// shadowed by [`WindowGranularity`] since T-1152. These constants remain as
|
||
/// internal spacing-multiplier values (see [`WindowGranularity::spacing_multiplier`],
|
||
/// [`clamp_window_n`]).
|
||
pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1;
|
||
pub const WINDOW_GRANULARITY_QUARTER: u32 = 4;
|
||
|
||
/// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"):
|
||
/// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`,
|
||
/// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly
|
||
/// at the ceiling (64² × 1² = 4,096), and quarter mode is clamped to
|
||
/// `n=16` districts across (16² × 4² = 4,096 — matching the design doc's
|
||
/// worked example, "Quarter, capped to same cell budget (n≈16 districts
|
||
/// across)") — this is the "extent shrinks as granularity refines, payload
|
||
/// stays ~constant" rule the design doc requires, enforced here (never
|
||
/// trusted from the wire) rather than merely asserted.
|
||
pub const WIRE_CAP_CELLS: u32 = 4_096;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WindowGranularity (T-1152, R5 redesign — D-226 T-1143-rulings amendment's
|
||
// "Wire-contract note (T-1150, PR #191 review — Tyre)")
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The full window-derivation-granularity vocabulary (T-1152), superseding
|
||
/// `window_granularity: u32`'s finer-than-district-only ceiling (Tyre's
|
||
/// wire-contract note, D-226 T-1143-rulings amendment: "the `u32` multiplier
|
||
/// field expresses finer-than-district integer multiples only ... a new
|
||
/// magic value is not the path"). This is the R5 redesign the note demands:
|
||
/// an explicit **named-variant enum**, the same wire pattern [`RoadNodeKind`]
|
||
/// already uses on this carrier (a plain `#[derive(Serialize, Deserialize)]`
|
||
/// enum with no `#[repr]`/manual impl serializes as its variant name over
|
||
/// `rmp_serde`, not an integer discriminant — deliberately NOT the
|
||
/// `repr(u8)`-cast-to-`Vec<u8>` convention the six dense per-cell arrays use;
|
||
/// this field is a scalar tag, not a bulk payload, so the string-tag
|
||
/// legibility is worth the few extra wire bytes one field costs).
|
||
///
|
||
/// **Why an enum and not a signed/log-scale int (the R5 alternative the risk
|
||
/// row named):** a log-scale `i32` still needs a lookup table to turn back
|
||
/// into a spacing, and a "cannot express" bug (someone passing `-2` and
|
||
/// expecting quarter-of-quarter) is silent at the type level. An enum with an
|
||
/// exhaustive match in [`WindowGranularity::spacing_m`] makes "this variant
|
||
/// has no defined spacing" a compile error, not a runtime surprise — the same
|
||
/// reasoning `MorphologyZone`'s exhaustive-match discipline already
|
||
/// established for this codebase (D-239 §6).
|
||
///
|
||
/// **Resolution (T-1152, simplified T-1159):** [`AtlasLayerRequest::window_granularity_v2`]
|
||
/// resolves directly via [`resolve_window_granularity_v2`], the single
|
||
/// widening point for this enum. Absent (`#[serde(default)]`, `None`)
|
||
/// resolves to `District`.
|
||
///
|
||
/// **T-1159:** this used to also reconcile against a legacy
|
||
/// `AtlasLayerRequest::window_granularity: u32` field (whenever THIS field
|
||
/// was absent) — that field is retired, fully shadowed since T-1152 and
|
||
/// never sent as anything but its byte-compatible default by any caller in
|
||
/// this codebase (no external client exists, single-repo client/server pair).
|
||
///
|
||
/// **Unknown → District** at every resolution boundary (never trust the
|
||
/// wire) — same posture as every other wire-decoded enum in this module.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||
pub enum WindowGranularity {
|
||
/// 512 m/cell (D-243 `QUARTER_M`) — finer than district, T-1150 Option B.
|
||
Quarter,
|
||
/// 2,048 m/cell (D-243 `DISTRICT_M`) — the original, still-default rung.
|
||
District,
|
||
/// 204,800 m/cell (D-243 `REGION_M`) — the coarser-than-district rung R5
|
||
/// flagged as unrepresentable in the `u32` multiplier encoding. Serves
|
||
/// BOTH the design doc's named "Region" row and the "Orbital/planetary"
|
||
/// row (R4: "Acceptable if the planetary rung's tiling effectively
|
||
/// subsumes it" — they share one derivation mode, `derive_orbital_at_metres`,
|
||
/// and one spacing; there is no third, coarser rung to distinguish them
|
||
/// by today, so one variant covers both named rows without inventing a
|
||
/// spacing the design doc never specified).
|
||
Region,
|
||
}
|
||
|
||
impl WindowGranularity {
|
||
/// Cell spacing in metres — the single source of truth every caller
|
||
/// (server derive loop, cap math, client mirror) must read through,
|
||
/// rather than re-deriving the metre value from the variant name.
|
||
/// Sourced from `scale::` (D-243), never a magic number local to this
|
||
/// module (T-1152 instruction: "Update the D-243-derived spacing
|
||
/// constants from `scale::` rather than new magic numbers").
|
||
pub fn spacing_m(self) -> f64 {
|
||
match self {
|
||
WindowGranularity::Quarter => crate::atlas::scale::QUARTER_M as f64,
|
||
WindowGranularity::District => DISTRICT_M as f64,
|
||
WindowGranularity::Region => crate::atlas::scale::REGION_M as f64,
|
||
}
|
||
}
|
||
|
||
/// The finer-than-district spacing multiplier ([`WINDOW_GRANULARITY_DISTRICT`]
|
||
/// / [`WINDOW_GRANULARITY_QUARTER`]) this variant corresponds to — `None`
|
||
/// for `Region`, which has no such multiplier (its spacing is coarser,
|
||
/// not a finer subdivision of a district). Used only by
|
||
/// [`clamp_window_n_v2`]'s District/Quarter branch to reuse
|
||
/// [`clamp_window_n`]'s per-axis-cap math rather than re-deriving it.
|
||
///
|
||
/// **T-1159:** this used to double as the wire-back-compat value for the
|
||
/// now-retired `window_granularity: u32` echo (`DistrictWindowLayer.granularity`)
|
||
/// — that role, and the `key_u32()` method that served it, are gone; this
|
||
/// is purely an internal spacing-multiplier lookup now.
|
||
fn spacing_multiplier(self) -> Option<u32> {
|
||
match self {
|
||
WindowGranularity::District => Some(WINDOW_GRANULARITY_DISTRICT),
|
||
WindowGranularity::Quarter => Some(WINDOW_GRANULARITY_QUARTER),
|
||
WindowGranularity::Region => None,
|
||
}
|
||
}
|
||
|
||
/// The derived cell-grid side length (in CELLS, at this granularity) for
|
||
/// a window whose extent is `n` DISTRICTS (T-1152 step 4: "work out what
|
||
/// n means at region granularity against D-243's region=100-district
|
||
/// side and document it" — this is that answer, made a total function
|
||
/// instead of inline arithmetic at each call site).
|
||
///
|
||
/// `n` is ALWAYS the window extent in DISTRICTS regardless of
|
||
/// granularity (T-1150 design doc §2, unchanged by this ticket: "the
|
||
/// window's `n` stays the DISTRICT extent"). The derived grid's side
|
||
/// length scales by [`Self::spacing_m`] relative to [`DISTRICT_M`]:
|
||
///
|
||
/// - `Quarter` (512 m, 4 cells/district side): `side = n * 4` — MORE
|
||
/// cells than districts requested (T-1150's existing behavior,
|
||
/// unchanged).
|
||
/// - `District` (2,048 m, 1:1): `side = n` — unchanged.
|
||
/// - `Region` (204,800 m = 100 districts/side, D-243
|
||
/// `DISTRICTS_PER_REGION`): `side = round(n / 100)`, minimum 1. A
|
||
/// region-granularity request with the SAME `n` as a district request
|
||
/// derives a FAR SMALLER cell grid (a region window with `n=64`
|
||
/// districts — the per-axis cap — derives only a 1×1 region cell,
|
||
/// since 64 districts is well under one region's 100-district side).
|
||
/// This inversion (finer rungs MULTIPLY cell count by `n`; `Region`
|
||
/// DIVIDES it) is why [`clamp_window_n`]'s wire-cap math needs a
|
||
/// region-aware branch too (see that function) — a flat
|
||
/// `n² × multiplier² ≤ WIRE_CAP_CELLS` formula would make `n` for
|
||
/// `Region` requests nonsensically tiny if `multiplier` were naively
|
||
/// `1/100`. Rounding (not floor/ceil) keeps the mapping the closest
|
||
/// integer approximation of the true ratio; minimum 1 so an `n` smaller
|
||
/// than one region never derives a degenerate empty grid.
|
||
pub fn cell_grid_side(self, n: u32) -> i32 {
|
||
match self {
|
||
WindowGranularity::Quarter => (n * WINDOW_GRANULARITY_QUARTER) as i32,
|
||
WindowGranularity::District => n as i32,
|
||
WindowGranularity::Region => {
|
||
let dpr = crate::atlas::scale::DISTRICTS_PER_REGION as f64;
|
||
((n as f64 / dpr).round() as i32).max(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a request's granularity to a [`WindowGranularity`] — **the single
|
||
/// widening point for the full (finer- and coarser-than-district) vocabulary**
|
||
/// (T-1152). Absent (`None`, `#[serde(default)]`) resolves to
|
||
/// [`WindowGranularity::District`] — unknown/malformed variants can't reach
|
||
/// this function at all (`rmp_serde` rejects an unrecognized enum variant
|
||
/// name at decode time, so "unknown" for this field means "absent", never
|
||
/// "present but garbage").
|
||
///
|
||
/// **T-1159:** this function used to fall back to the legacy `window_granularity: u32`
|
||
/// field (via the now-removed `resolve_window_granularity`) when
|
||
/// `window_granularity_v2` was absent — that fallback is retired along with
|
||
/// the field itself (fully shadowed since T-1152, no pre-T-1152 client
|
||
/// exists). A future coarser-than-region rung is added by widening this
|
||
/// function's match AND [`WindowGranularity`]'s variant list together.
|
||
fn resolve_window_granularity_v2(req: &AtlasLayerRequest) -> WindowGranularity {
|
||
req.window_granularity_v2
|
||
.unwrap_or(WindowGranularity::District)
|
||
}
|
||
|
||
/// Clamp `window_n` against BOTH the existing per-axis cap
|
||
/// ([`DISTRICT_WINDOW_MAX_N`]) and the granularity-aware wire-size ceiling
|
||
/// ([`WIRE_CAP_CELLS`]) — `window_n² × granularity² ≤ WIRE_CAP_CELLS` (T-1150
|
||
/// design doc §3). Applied AFTER the per-axis clamp so a request that already
|
||
/// satisfies `DISTRICT_WINDOW_MAX_N` still shrinks further at granularity 4.
|
||
///
|
||
/// **This clamp is echoed, not silently applied** — `serve_district_window`
|
||
/// puts the CLAMPED `n` into `DistrictWindowLayer.n`, so a client that
|
||
/// requests an oversized `n` gets back a smaller one. Any client-side
|
||
/// staleness guard comparing its own requested `n` against the echo MUST
|
||
/// mirror this exact function first (PR #191 review, Tyre C1) — see
|
||
/// `atlas_window_request.gd`'s `_clamp_window_n_mirror()`, which matches this
|
||
/// function bit-for-bit, the same load-bearing-mirror pattern
|
||
/// `canonicalize_district_center()` (`atlas_descend_geometry.gd`) already
|
||
/// uses for `normalize_window_center`.
|
||
fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 {
|
||
let n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N);
|
||
let g = granularity.max(1);
|
||
let cap_n = (WIRE_CAP_CELLS as f64).sqrt() / g as f64;
|
||
n.min(cap_n.floor().max(1.0) as u32)
|
||
}
|
||
|
||
/// [`WindowGranularity`]-aware twin of [`clamp_window_n`] (T-1152 step 4) —
|
||
/// the SAME two-stage discipline (per-axis clamp, THEN the wire-size
|
||
/// ceiling on the DERIVED cell count, never trusted from the wire), but
|
||
/// computed through [`WindowGranularity::cell_grid_side`] so it is correct
|
||
/// for BOTH directions (finer multiplies cell count; `Region` divides it —
|
||
/// see that method's doc) instead of assuming the finer-only
|
||
/// `n × multiplier` relationship [`clamp_window_n`] hard-codes.
|
||
///
|
||
/// - **`District`/`Quarter`:** per-axis cap is [`DISTRICT_WINDOW_MAX_N`]
|
||
/// (64, unchanged) — byte-identical clamped `n` to [`clamp_window_n`] for
|
||
/// every input these two variants can produce (verified by
|
||
/// `clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter`,
|
||
/// below).
|
||
/// - **`Region`:** per-axis cap is [`DISTRICT_WINDOW_MAX_N_REGION`] (6,400 —
|
||
/// see that constant's doc for the derivation), then a halving loop walks
|
||
/// `n` back if `cell_grid_side(n)` would still exceed `sqrt(WIRE_CAP_CELLS)`
|
||
/// region cells across.
|
||
///
|
||
/// **This loop is defensive, not currently reachable — stated plainly, not
|
||
/// left implicit.** `DISTRICT_WINDOW_MAX_N_REGION` is DERIVED as
|
||
/// `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION` specifically so the
|
||
/// per-axis clamp alone already forecloses the loop's trigger condition: a
|
||
/// brute-force sweep of every `raw_n` in `[1, DISTRICT_WINDOW_MAX_N_REGION]`
|
||
/// shows `cell_grid_side(n)` never exceeds `sqrt(WIRE_CAP_CELLS)` (64), so
|
||
/// `n /= 2` never executes for any input the per-axis clamp lets through —
|
||
/// verified by `clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs`,
|
||
/// which pins BOTH the invariant (`cell_grid_side(result)² ≤ WIRE_CAP_CELLS`)
|
||
/// AND the loop's current no-op status (`result == raw_n.clamp(1,
|
||
/// DISTRICT_WINDOW_MAX_N_REGION)` for every swept input). The loop is kept
|
||
/// anyway as the general, correct algorithm (region's `cell_grid_side` is a
|
||
/// ROUNDING division, not the finer rungs' exact multiplication, so there
|
||
/// is no closed-form inverse the way `cap_n = sqrt(WIRE_CAP_CELLS) / g` is
|
||
/// for the finer case) — it is the safety net for a FUTURE cap derivation
|
||
/// that doesn't land exactly on the boundary (a new rung from a later
|
||
/// measurement pass, or a `WIRE_CAP_CELLS` retune that isn't a perfect
|
||
/// square times `DISTRICTS_PER_REGION`). If a future constant change makes
|
||
/// the loop actually fire, the pinned no-op assertion above breaks loudly,
|
||
/// forcing a deliberate look rather than a silent behavior change.
|
||
fn clamp_window_n_v2(raw_n: u32, granularity: WindowGranularity) -> u32 {
|
||
match granularity {
|
||
WindowGranularity::District | WindowGranularity::Quarter => clamp_window_n(
|
||
raw_n,
|
||
granularity
|
||
.spacing_multiplier()
|
||
.unwrap_or(WINDOW_GRANULARITY_DISTRICT),
|
||
),
|
||
WindowGranularity::Region => {
|
||
let mut n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION);
|
||
while granularity.cell_grid_side(n).pow(2) as u32 > WIRE_CAP_CELLS && n > 1 {
|
||
n /= 2;
|
||
}
|
||
n.max(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Quantized `window_min_wl_m` bands (T-1150, zoom ladder design doc §5;
|
||
/// retuned T-1162 part (c); band 4 re-derived per Tyre's PR #194 review I1).
|
||
/// `0` (no cutoff) plus the two coarse legacy bands
|
||
/// [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`]`[0..2]` (32,768/
|
||
/// 16,384/8,192 m — carried over unchanged, no rung claims these as its own
|
||
/// Nyquist floor), plus **District's own Nyquist floor** computed directly
|
||
/// from the rung's spacing (`2 × DISTRICT_M = 4,096` m), plus **Quarter's own
|
||
/// Nyquist floor** (`2 × QUARTER_M = 1,024` m).
|
||
///
|
||
/// **Dependency direction (Tyre, PR #194 I1):** the cutoff's semantic job is
|
||
/// Nyquist truncation — a property of the RUNG (its sample spacing), never of
|
||
/// any one invention field's octave choices. Band 4 is therefore derived from
|
||
/// `scale::DISTRICT_M` directly, NOT from
|
||
/// `detail_scatter::OCTAVE_WAVELENGTHS_M[3]` (the old T-1162 wiring) — that
|
||
/// coupling ran backwards: a future `detail_scatter` retune could silently
|
||
/// redefine the Atlas rung floor, and the coast warp already extends its OWN
|
||
/// octaves below `OCTAVE_WAVELENGTHS_M`'s range (T-1162 part a), proving no
|
||
/// single invention field's array is entitled to dictate the floor. The two
|
||
/// values happen to coincide today (4,096 = `2 × DISTRICT_M` = also
|
||
/// `OCTAVE_WAVELENGTHS_M[3]`) — the `const _: () = assert!(...)` immediately
|
||
/// below pins that coincidence so a future drift on EITHER side breaks the
|
||
/// build loudly instead of silently decoupling the two meanings.
|
||
///
|
||
/// **Pre-T-1162 bands unchanged in VALUE** (32,768/16,384/8,192/4,096 stay
|
||
/// exactly where they were) — but **NOT unchanged in behavior at 8,192/4,096
|
||
/// specifically**: `terrain_detail`'s own octave array is untouched by this
|
||
/// ticket, so THAT contribution is byte-identical at every pre-existing band,
|
||
/// but the coast warp's octave array gained genuinely new 8,192/4,096 m
|
||
/// entries (T-1162 part a) that a District-rung request quantizing to those
|
||
/// bands now legitimately admits — District's real Nyquist floor (4,096 m)
|
||
/// is coarse enough to resolve that content, so this is intended enrichment
|
||
/// at District too, not a leak (see `coast_invention::WARP_OCTAVE_WAVELENGTHS_M`'s
|
||
/// doc for the full admit/exclude table). Only the 32,768/16,384 bands are
|
||
/// truly inert-to-this-ticket (no octave in any extended array falls in that
|
||
/// range). The sub-district relief band ([`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`],
|
||
/// all ≤1,024 m) and the coast warp's two finest additions (2,048/1,024 m)
|
||
/// are genuinely excluded at every pre-existing band (all ≥4,096) — those are
|
||
/// Quarter-exclusive, admitted only by the new `1,024` band. The new band is
|
||
/// additive at the END of the array — appending, not reordering, keeps every
|
||
/// existing index-based reference to the first four entries valid.
|
||
///
|
||
/// Descending order except the leading `0.0` sentinel, matched by
|
||
/// `quantize_min_wl_m`'s scan below.
|
||
const MIN_WL_BANDS_M: [f64; 6] = [
|
||
0.0,
|
||
crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[0],
|
||
crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[1],
|
||
crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[2],
|
||
2.0 * DISTRICT_M as f64,
|
||
2.0 * crate::atlas::scale::QUARTER_M as f64,
|
||
];
|
||
|
||
/// Direction-agnostic guard (Tyre, PR #194 I1): `MIN_WL_BANDS_M`'s District
|
||
/// band is now derived from `2 × DISTRICT_M`, deliberately decoupled from
|
||
/// `detail_scatter::OCTAVE_WAVELENGTHS_M[3]` (see the doc above). The two
|
||
/// values are expected to keep coinciding — `terrain_detail`'s finest octave
|
||
/// IS meant to sit at District's Nyquist floor — but nothing in the type
|
||
/// system enforces that anymore now that the dependency runs one direction
|
||
/// only. This assert exists so that if either side ever drifts (a
|
||
/// `detail_scatter` octave retune, or a `DISTRICT_M` scale-ladder change).
|
||
/// the build breaks loudly and a human decides deliberately whether the
|
||
/// coincidence should be restored or the two meanings were meant to diverge
|
||
/// — never a silent redefinition of the Atlas rung floor.
|
||
const _: () =
|
||
assert!(crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[3] as i64 == 2 * DISTRICT_M as i64);
|
||
|
||
/// Snap a wire-supplied `window_min_wl_m` to the nearest fixed band in
|
||
/// [`MIN_WL_BANDS_M`] (T-1150, design doc §5's gap-fix): "as specified,
|
||
/// `window_min_wl_m` is viewport-continuous while the cache key/echo tuple is
|
||
/// `(body, center, n, granularity)` — same key, different `min_wl`, would
|
||
/// silently collide. Fix: quantize `min_wl_m` to a small fixed set of bands
|
||
/// ... and add the quantized band to both the echo and the cache key." This
|
||
/// is that quantization, applied unconditionally to every request before it
|
||
/// touches either the cache key or the `DeriveWindow` work item — **never
|
||
/// the raw wire value past this point**, same discipline as `window_n`'s
|
||
/// clamp and `window_center`'s normalization. Nearest-band snap (ties round
|
||
/// to the coarser/lower band, i.e. `<=` on the running best distance) keeps
|
||
/// the mapping total and deterministic for any `u32` input, including values
|
||
/// far outside the octave range (e.g. `u32::MAX` snaps to the coarsest band).
|
||
pub(crate) fn quantize_min_wl_m(raw: u32) -> u32 {
|
||
let raw_f = raw as f64;
|
||
let mut best = MIN_WL_BANDS_M[0];
|
||
let mut best_dist = (raw_f - best).abs();
|
||
for &band in &MIN_WL_BANDS_M[1..] {
|
||
let dist = (raw_f - band).abs();
|
||
if dist < best_dist {
|
||
best = band;
|
||
best_dist = dist;
|
||
}
|
||
}
|
||
best as u32
|
||
}
|
||
|
||
/// A client request for a body's generation layers (D-225), extended with an
|
||
/// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137).
|
||
///
|
||
/// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item`
|
||
/// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::Region`
|
||
/// (the terminal layer, T-1113) unconditionally on every request, ignoring this
|
||
/// field. Wiring per-request depth (and the partial caching it implies) is
|
||
/// deferred to #1021.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AtlasLayerRequest {
|
||
pub body_id: String,
|
||
pub up_to: CascadeLayer,
|
||
/// District-window centre (D-226 T-1124 amendment §1, T-1137). `None` = no
|
||
/// window requested (whole-body layers only — today's behavior, byte-unchanged
|
||
/// for every existing caller thanks to `#[serde(default)]`).
|
||
#[serde(default)]
|
||
pub window_center: Option<DistrictPos>,
|
||
/// Window side length in districts. Ignored when `window_center` is `None`.
|
||
/// Clamped server-side to `[1, DISTRICT_WINDOW_MAX_N]` — **never trusted
|
||
/// from the wire** (D-226 T-1124 amendment §4).
|
||
#[serde(default)]
|
||
pub window_n: u32,
|
||
/// Window derivation granularity (T-1152, R5 redesign — see
|
||
/// [`WindowGranularity`]'s doc for the full rationale). `#[serde(default)]`
|
||
/// (`None`) resolves to [`WindowGranularity::District`] (today's default
|
||
/// behavior, byte-compatible with every pre-T-1150 caller) via
|
||
/// [`resolve_window_granularity_v2`] — **never trusted from the wire**,
|
||
/// unrecognized/absent values fall back to district.
|
||
///
|
||
/// **T-1159:** the legacy `window_granularity: u32` field this superseded
|
||
/// (T-1150's finer-than-district-only encoding) is retired — this enum
|
||
/// fully shadowed it since T-1152 landed, and no pre-T-1152 client exists
|
||
/// (single-repo client/server pair). See D-255(c): the `district_window`
|
||
/// carrier itself stays alive byte-unchanged for its existing consumer;
|
||
/// only the redundant `u32` alongside this enum is gone.
|
||
#[serde(default)]
|
||
pub window_granularity_v2: Option<WindowGranularity>,
|
||
/// Octave cutoff for the invented-terrain scatter (T-1149's
|
||
/// `min_wavelength_m`), in whole metres. `0` (absent) = no cutoff = the
|
||
/// pre-T-1150 behavior.
|
||
///
|
||
/// **Quantization contract (Hoshe 1 / Tyre C3, PR #191 review; design doc
|
||
/// §5):** the wire value here is an UNQUANTIZED, unclamped raw passthrough
|
||
/// — client codecs may send any `u32`. The SERVER is the one place
|
||
/// quantization happens: `serve_district_window` snaps every request's
|
||
/// value to the nearest fixed band in
|
||
/// [`MIN_WL_BANDS_M`] via [`quantize_min_wl_m`] BEFORE it ever touches
|
||
/// the cache key or the `DeriveWindow` work item, and the QUANTIZED value
|
||
/// (not this raw field) is what gets echoed back on
|
||
/// `DistrictWindowLayer.min_wl_m` and used as the cache key component.
|
||
/// This closes the §5 gap: without quantization, two requests differing
|
||
/// only in a continuous-valued `min_wl_m` would silently miss each
|
||
/// other's cache entries (the unbounded-key-space problem §5 exists to
|
||
/// close) — the client is free to send a viewport-continuous estimate;
|
||
/// the server's quantization is what makes the key space bounded again.
|
||
#[serde(default)]
|
||
pub window_min_wl_m: u32,
|
||
}
|
||
|
||
/// Status of a layer response (D-225).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum AtlasLayerStatus {
|
||
/// Layer data is ready (`layer1` is populated).
|
||
Ready,
|
||
/// Analysis was enqueued; the client should re-request shortly.
|
||
Pending,
|
||
/// The body is unknown or has no source terrain — re-requesting won't help.
|
||
NotFound,
|
||
/// Resolution / IO failure (message for the client log).
|
||
Error(String),
|
||
}
|
||
|
||
/// The cascade's coarse district grid, surfaced for the Atlas generation overlay
|
||
/// (T-1046, D-226). This is the **planetary-scale** view — one cell per coarse
|
||
/// grid square (`grid_w/cols` heightmap pixels) — not the on-demand 2 km districts
|
||
/// (those derive only when a player enters a settlement, Phase 5). `morphology`
|
||
/// and `elev_q` are row-major (`rows × cols`); `morphology[i]` is a `MorphologyZone`
|
||
/// discriminant (D-239 §6, `repr(u8)`), `elev_q[i]` is 0–100 elevation for relief
|
||
/// shading. The client maps `cols × rows` onto the displayed heightmap.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct DistrictGridLayer {
|
||
pub cols: u32,
|
||
pub rows: u32,
|
||
pub morphology: Vec<u8>,
|
||
pub elev_q: Vec<u8>,
|
||
}
|
||
|
||
/// A layer response: the computed `Layer1Output` + the coarse district grid
|
||
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) +
|
||
/// the region climate grid (T-1113) + the quarter-footprint overlay (T-1112,
|
||
/// T-1119) + the district-resolution window query (T-1124, T-1137), or a
|
||
/// non-ready status.
|
||
///
|
||
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
|
||
/// pattern tops out at six fields for the **dense whole-body layer family**
|
||
/// (`district_grid`, `road_graph`, `settlements`, `region_grid`,
|
||
/// `quarter_footprints` — each a compute-once, cache-per-body snapshot) —
|
||
/// D-226's 2026-07-13 amendment (d) rules out any L5/tile Atlas layer ever,
|
||
/// and `quarter_footprints` below is the last candidate the 2026-07-16 T-1112
|
||
/// amendment named. **That budget is now consumed:** a seventh *whole-body*
|
||
/// field is not a naming exercise like the six before it — a future
|
||
/// generation-layer addition needs its own governance, not a drive-by field.
|
||
///
|
||
/// The D-226 T-1124 amendment (2026-07-18) RESOLVED what carries the next
|
||
/// addition, and it is NOT this family: a **windowed viewport query** is a
|
||
/// categorically different payload (keyed on the *request* `(body, center, n)`,
|
||
/// re-fetched per pan, not a per-body snapshot). `district_window` (wired here,
|
||
/// T-1137) rides on `AtlasLayerResponse` but is explicitly OUTSIDE the
|
||
/// whole-body family and does not count against the six-field ceiling above
|
||
/// (D-226 T-1124 §2). The windowed family has its own hard cap: exactly ONE
|
||
/// windowed-query field; a second windowed query (a second viewport, a
|
||
/// windowed chunk-preview) is a dedicated response message by rule, not a
|
||
/// second `Option` here (D-226 T-1124 §2, symmetric with the request-side
|
||
/// five-shape demux ceiling in `bridge/mod.rs`).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AtlasLayerResponse {
|
||
pub body_id: String,
|
||
pub status: AtlasLayerStatus,
|
||
pub layer1: Option<Layer1Output>,
|
||
/// The coarse district/morphology grid for the Atlas overlay (T-1046).
|
||
/// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise.
|
||
pub district_grid: Option<DistrictGridLayer>,
|
||
/// The inter-settlement road/rail graph overlay (T-960 §1, T-1038).
|
||
/// `Some` on a cache hit once the RoadGraph layer has run; `None` otherwise
|
||
/// (including a body with zero placed settlements — an empty graph has no
|
||
/// nodes to draw, so it collapses to `None` the same way `district_grid`
|
||
/// does for an unrun layer).
|
||
pub road_graph: Option<RoadGraphLayer>,
|
||
/// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit
|
||
/// once the Settlement layer has placed at least one city; `None` otherwise.
|
||
pub settlements: Option<SettlementLayer>,
|
||
/// The region climate grid for the Atlas overlay (D-243 §3, T-1113).
|
||
/// `Some` on a cache hit once the Region layer has run; `None` otherwise.
|
||
pub region_grid: Option<RegionGridLayer>,
|
||
/// The requested district window (D-226 T-1124 amendment, T-1137), or
|
||
/// `None` when the request carried no `window_center` / no window data is
|
||
/// cached yet for a pending derive. Distinct from the five layers above:
|
||
/// keyed on the REQUEST `(body, center, n)`, not on the body alone — see
|
||
/// the struct-level doc.
|
||
pub district_window: Option<DistrictWindowLayer>,
|
||
/// The quarter-footprint overlay (D-226 T-1112 amendment, T-1119). `Some`
|
||
/// on a cache hit once at least one settlement's quarter skeleton has been
|
||
/// generated (`state.quarters` non-empty); `None` otherwise, including a
|
||
/// body with placed settlements whose quarters haven't finished the async
|
||
/// `GenerateSkeleton` pass yet (skeleton generation runs at `Low` priority
|
||
/// after the body's own `Ready` snapshot is cached — see `plugin.rs`).
|
||
pub quarter_footprints: Option<QuarterFootprintLayer>,
|
||
}
|
||
|
||
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
|
||
/// Returns `None` when the DistrictProfile layer has not run (empty `districts`).
|
||
/// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full
|
||
/// heightmap), so the extent comes from the maximum `SurveyCellPos` (D-256(b)
|
||
/// — this is the coarse survey raster, not the true district grid).
|
||
pub fn build_district_grid(
|
||
state: &crate::atlas::body_world_state::BodyWorldState,
|
||
) -> Option<DistrictGridLayer> {
|
||
if state.districts.is_empty() {
|
||
return None;
|
||
}
|
||
let cols = state.districts.keys().map(|p| p.0).max().unwrap_or(0) as u32 + 1;
|
||
let rows = state.districts.keys().map(|p| p.1).max().unwrap_or(0) as u32 + 1;
|
||
let n = (cols * rows) as usize;
|
||
let mut morphology = vec![0u8; n];
|
||
let mut elev_q = vec![0u8; n];
|
||
for (pos, profile) in &state.districts {
|
||
let (x, y) = (pos.0, pos.1);
|
||
if x < 0 || y < 0 {
|
||
continue;
|
||
}
|
||
let i = (y as u32 * cols + x as u32) as usize;
|
||
if i < n {
|
||
morphology[i] = profile.morphology_zone as u8;
|
||
elev_q[i] = profile.elev_q.clamp(0, 100) as u8;
|
||
}
|
||
}
|
||
Some(DistrictGridLayer {
|
||
cols,
|
||
rows,
|
||
morphology,
|
||
elev_q,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RegionGridLayer (T-1113, D-243 §3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The ~205 km region climate grid for the Atlas overlay (T-1113), dense
|
||
/// row-major like [`DistrictGridLayer`] (the T-1046 encoding precedent).
|
||
/// Serves the **mean-state** `RegionClock` fields only — the Q-105 tick-phase
|
||
/// callbacks are deferred, so what ships is the static climate context.
|
||
///
|
||
/// Wire encoding is all-integer (D-010 wire discipline):
|
||
/// - `season[i]` / `weather[i]` — the `repr(u8)` discriminants of
|
||
/// `SeasonPhase` / `WeatherState` (pinned, append-only).
|
||
/// - `mean_temp_dc[i]` — mean-annual temperature baseline in **deci-°C**
|
||
/// (×10, `round`ed; 0.1 °C is ample for a map overlay). `i16::MIN` is the
|
||
/// sentinel for "no atmosphere → no temperature" (airless bodies carry
|
||
/// `mean_temp_c: None`); real values are class-band-clamped far inside
|
||
/// i16 range.
|
||
/// - `moisture_q[i]` — the 0–100 region moisture primitive.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RegionGridLayer {
|
||
pub cols: u32,
|
||
pub rows: u32,
|
||
pub season: Vec<u8>,
|
||
pub weather: Vec<u8>,
|
||
pub mean_temp_dc: Vec<i16>,
|
||
pub moisture_q: Vec<u8>,
|
||
}
|
||
|
||
/// Sentinel for "airless body — no temperature baseline" in
|
||
/// [`RegionGridLayer::mean_temp_dc`].
|
||
pub const REGION_TEMP_NONE_DC: i16 = i16::MIN;
|
||
|
||
/// Build the [`RegionGridLayer`] from a body's cached state (T-1113).
|
||
/// Returns `None` when the Region layer has not run (empty `regions`).
|
||
/// The stored region set is the dense covering grid `[0, cols) × [0, rows)`
|
||
/// (see `cascade::LayerRegionOutput` — no blend-padding ring), so the extent
|
||
/// comes from the maximum `RegionPos`, mirroring [`build_district_grid`].
|
||
pub fn build_region_grid(
|
||
state: &crate::atlas::body_world_state::BodyWorldState,
|
||
) -> Option<RegionGridLayer> {
|
||
if state.regions.is_empty() {
|
||
return None;
|
||
}
|
||
let cols = state.regions.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1;
|
||
let rows = state.regions.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1;
|
||
let n = (cols * rows) as usize;
|
||
let mut season = vec![0u8; n];
|
||
let mut weather = vec![0u8; n];
|
||
let mut mean_temp_dc = vec![REGION_TEMP_NONE_DC; n];
|
||
let mut moisture_q = vec![0u8; n];
|
||
for (&(x, y), profile) in &state.regions {
|
||
if x < 0 || y < 0 {
|
||
continue;
|
||
}
|
||
let i = (y as u32 * cols + x as u32) as usize;
|
||
if i < n {
|
||
season[i] = profile.clock.season as u8;
|
||
weather[i] = profile.clock.weather as u8;
|
||
mean_temp_dc[i] = match profile.clock.mean_temp_c {
|
||
Some(t) => {
|
||
((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16
|
||
}
|
||
None => REGION_TEMP_NONE_DC,
|
||
};
|
||
moisture_q[i] = profile.moisture_q.clamp(0, 100) as u8;
|
||
}
|
||
}
|
||
Some(RegionGridLayer {
|
||
cols,
|
||
rows,
|
||
season,
|
||
weather,
|
||
mean_temp_dc,
|
||
moisture_q,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DistrictWindowLayer (D-226 T-1124 amendment, T-1137)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The requested district window: an `n × n` grid of TRUE 2 km districts
|
||
/// (or, at `granularity = 4`, an effective `(4n) × (4n)` grid of 512 m
|
||
/// quarters covering the SAME world extent — see `granularity` doc below),
|
||
/// derived on-demand via `district_profile::derive_district`/`derive_at_metres`
|
||
/// (D-226 T-1124 amendment §2, T-1150). **Echoes `center`/`n`/`granularity`
|
||
/// back** — this is the client's race-condition guard, not a convenience
|
||
/// field: because the derivation is pure and deterministic (D-227), the same
|
||
/// `(center, n, granularity, min_wl_m)` query always yields the same payload,
|
||
/// so the echoed tuple *is* the cache/staleness key the client compares
|
||
/// against its most recently requested window (`body_id` disambiguation
|
||
/// rides the enclosing `AtlasLayerResponse`, not the echo — see the
|
||
/// amendment).
|
||
///
|
||
/// All six arrays are dense row-major (`i = row * side + col`, where `side`
|
||
/// is `n` at district granularity or `4n` at quarter granularity), matching
|
||
/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell
|
||
/// wire cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead
|
||
/// (D-226 T-1124 amendment §4).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct DistrictWindowLayer {
|
||
pub center: DistrictPos,
|
||
/// Window extent in DISTRICTS — this does NOT change with granularity
|
||
/// (T-1150 design doc §2: "the window's `n` stays the DISTRICT extent").
|
||
/// The derived cell grid's actual side length is `n * granularity`.
|
||
pub n: u32,
|
||
/// The R5-redesigned granularity (T-1152) — see [`WindowGranularity`]'s
|
||
/// doc. Always populated (never `None`): the server always resolves a
|
||
/// concrete rung internally via [`resolve_window_granularity_v2`].
|
||
///
|
||
/// **T-1159:** the legacy `granularity: u32` echo this field used to sit
|
||
/// alongside (a wire-back-compat value for pre-T-1152 clients, which do
|
||
/// not exist — single-repo client/server pair) is retired. This enum
|
||
/// echo has been the sole granularity signal since T-1152.
|
||
pub granularity_v2: WindowGranularity,
|
||
/// The `min_wavelength_m` octave cutoff (T-1149) this window was derived
|
||
/// with, in whole metres (`0` = no cutoff). Echoed for the same reason as
|
||
/// `granularity` — two windows at identical `(center, n, granularity)`
|
||
/// but different cutoffs are NOT the same payload and must not alias.
|
||
pub min_wl_m: u32,
|
||
/// `MorphologyZone` discriminant, the frozen 17-zone vocabulary (D-239 §6).
|
||
pub morphology: Vec<u8>,
|
||
/// 0-100, matches `DistrictGridLayer.elev_q` encoding.
|
||
pub elev_q: Vec<u8>,
|
||
/// Deci-°C, [`REGION_TEMP_NONE_DC`] sentinel — the SAME scheme as
|
||
/// `RegionGridLayer.mean_temp_dc`, deliberately not a separate
|
||
/// district-tier quantization (one temperature colorizer spans both zoom
|
||
/// levels, D-226 T-1124 amendment §2).
|
||
pub temp_dc: Vec<i16>,
|
||
/// 0-100, matches `DistrictGridLayer` precedent.
|
||
pub moisture_q: Vec<u8>,
|
||
/// `VegetationClass` discriminant, 0-6 including `Marine = 6` (T-1126) —
|
||
/// any client palette MUST be exhaustive over `Marine` (D-226 T-1124
|
||
/// amendment §3, non-negotiable — the ocean-blind-vegetation bug this
|
||
/// field caught at the district tier).
|
||
pub vegetation: Vec<u8>,
|
||
/// `GlaciationGrade` discriminant, 0-4 (T-1127).
|
||
pub glaciation: Vec<u8>,
|
||
/// Invented river course polylines intersecting this window (T-1170,
|
||
/// Ruling 1b/1c/3h). **Not part of the windowed-family ceiling** (D-226
|
||
/// T-1124 §2 [HARD]) — that ceiling counts windowed-QUERY fields; this is
|
||
/// content of the ONE existing windowed payload, arriving on the same
|
||
/// echo key with the same staleness semantics as the six dense arrays
|
||
/// above (governance capture: `governance/decisions/architecture.md`,
|
||
/// D-226 amendment 2026-07-23, course-invention carrier note).
|
||
/// `#[serde(default)]` — the additive T-1124 §1 pattern: a pre-T-1170
|
||
/// payload/fixture decodes to an empty `Vec`, never an error.
|
||
#[serde(default)]
|
||
pub courses: Vec<RiverCourse>,
|
||
}
|
||
|
||
/// One invented river course polyline intersecting a window (T-1170, Ruling
|
||
/// 3h). Only edges whose amplitude-inflated chord bounding box intersects the
|
||
/// window ship; `points` are cropped to the window plus one station beyond
|
||
/// each edge of it (so client-side polyline drawing has continuity into the
|
||
/// next window without needing to stitch across a request boundary).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RiverCourse {
|
||
/// The packed upstream-cell id (`river_course::pack_cell_id`) — the
|
||
/// edge's stable identity (Ruling 2d), stable across every window/rung
|
||
/// that ships this same edge.
|
||
pub edge_id: u32,
|
||
/// `river_class` at the edge's upstream cell (0=stream, 1=tributary,
|
||
/// 2=trunk) — the SAME vocabulary `RiverNetwork.river_class` uses, so
|
||
/// client-side per-rung/per-class filtering (Araminta's presentation
|
||
/// tables, Ruling 5c) reuses the existing decode path.
|
||
pub class: u8,
|
||
/// Points along the course, in absolute world metres, cropped to this
|
||
/// window (+ one station beyond each edge, Ruling 3h).
|
||
pub points: Vec<(i32, i32)>,
|
||
/// How this course's downstream end resolves (Ruling 3e/3f) — `None` when
|
||
/// the course's true downstream terminus (whether `Mouth` or
|
||
/// `ContinuesBeyondWindow`) falls outside this window's cropped point
|
||
/// range, so nothing about the terminus can be asserted from this
|
||
/// payload alone.
|
||
pub terminus: CourseTerminus,
|
||
}
|
||
|
||
/// [`RiverCourse::terminus`] — the course's downstream-end classification on
|
||
/// the wire (Ruling 3h).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum CourseTerminus {
|
||
/// The course's downstream end is not within this window's cropped point
|
||
/// range — the real terminus (whatever it is) lies in a different window.
|
||
None,
|
||
/// The course reaches a real sea/lake crossing within this window (Ruling
|
||
/// 3e) — the last point in `points` is the resolved invented-coast
|
||
/// terminus.
|
||
Mouth,
|
||
/// The course reaches a grid-edge drain (Ruling 3f) — a grid artifact,
|
||
/// not a mouth; the last point in `points` is the last in-grid station,
|
||
/// with no mouth marker implied.
|
||
EdgeDrain,
|
||
/// The course's downstream end is a real river cell beyond this window's
|
||
/// crop range — i.e. an `Interior`-terminus edge whose full extent is
|
||
/// wider than what got cropped in. The client draws the polyline without
|
||
/// a terminus marker and expects it to continue in an adjacent window.
|
||
ContinuesBeyondWindow,
|
||
}
|
||
|
||
/// Key for the server-side window derive cache (T-1137, extended T-1150,
|
||
/// extended again T-1152): `(body_id, center, n, granularity, min_wl_m)`.
|
||
/// D-227 purity means a cached window is valid forever for a given
|
||
/// body+seed — no staleness/TTL invalidation is needed, only a bound on
|
||
/// unbounded growth (see [`DistrictWindowCache`]). `granularity`/`min_wl_m`
|
||
/// MUST be part of the key — the design doc's aliasing risk (§3): a
|
||
/// granularity-4 request at the same `(body, center, n)` as a granularity-1
|
||
/// request is a DIFFERENT payload and must land in a different cache slot,
|
||
/// never silently overwrite or be served by the other.
|
||
///
|
||
/// **The granularity slot is [`WindowGranularity`] itself (T-1152), not the
|
||
/// legacy `u32`** — carrying the full enum here (rather than relying on
|
||
/// [`WindowGranularity::key_u32`]'s reserved-sentinel trick alone) is what
|
||
/// makes a `Region` window's cache slot structurally distinct from a
|
||
/// `District`/`Quarter` one, satisfying the "carry the new representation"
|
||
/// requirement at the cache-key touch point directly rather than through an
|
||
/// encoding side-channel. `WindowGranularity`'s `Ord` derive (declaration
|
||
/// order: `Quarter < District < Region`) makes this legal as a `BTreeMap`
|
||
/// key (D-010 determinism — ordered iteration, no `HashMap`).
|
||
pub type DistrictWindowKey = (String, DistrictPos, u32, WindowGranularity, u32);
|
||
|
||
/// Bounded LRU-ish cache of completed district-window derives (T-1137), a
|
||
/// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are
|
||
/// keyed on the *request* `(body, center, n)`, not the body alone (see the
|
||
/// struct-level doc on [`AtlasLayerResponse`]), so they don't fit the
|
||
/// per-body cache's keying at all. Eviction is capacity-only FIFO-by-insertion
|
||
/// (not access-recency LRU like `BodyWorldStateCache`) — window requests are
|
||
/// comparatively rare and cheap to re-derive on a genuine miss (a background
|
||
/// re-submit, never a stall), so exact recency tracking isn't worth the
|
||
/// bookkeeping; a simple bound against unbounded growth is enough.
|
||
#[derive(Resource, Debug, Default)]
|
||
pub struct DistrictWindowCache {
|
||
entries: std::collections::BTreeMap<DistrictWindowKey, DistrictWindowLayer>,
|
||
/// Insertion order, oldest first — the eviction queue.
|
||
order: std::collections::VecDeque<DistrictWindowKey>,
|
||
capacity: usize,
|
||
}
|
||
|
||
/// Default capacity for [`DistrictWindowCache`] — generous relative to
|
||
/// `BodyWorldStateCache::CACHE_CAPACITY` (50 bodies) since each entry here is
|
||
/// far smaller (a handful of `Vec<u8>`/`Vec<i16>` at `n ≤ 64`, ≤ 28 KiB raw vs.
|
||
/// `BodyWorldState`'s full heightmap + districts + regions), and several
|
||
/// windows can legitimately be live per body (a player panning around).
|
||
pub const DISTRICT_WINDOW_CACHE_CAPACITY: usize = 256;
|
||
|
||
impl DistrictWindowCache {
|
||
pub fn new(capacity: usize) -> Self {
|
||
Self {
|
||
entries: std::collections::BTreeMap::new(),
|
||
order: std::collections::VecDeque::new(),
|
||
capacity,
|
||
}
|
||
}
|
||
|
||
/// Look up a cached window by its full key. Never mutates — window
|
||
/// validity has no time component (D-227), so there is nothing to bump.
|
||
pub fn get(&self, key: &DistrictWindowKey) -> Option<&DistrictWindowLayer> {
|
||
self.entries.get(key)
|
||
}
|
||
|
||
/// Insert a completed window derive, evicting the oldest entry first if
|
||
/// at capacity. Re-inserting an existing key replaces the value without
|
||
/// moving it in the eviction order (D-227: the value can only ever be
|
||
/// identical, so this is a no-op in practice, but stays correct either way).
|
||
pub fn insert(&mut self, key: DistrictWindowKey, layer: DistrictWindowLayer) {
|
||
if !self.entries.contains_key(&key) {
|
||
if self.entries.len() >= self.capacity {
|
||
if let Some(victim) = self.order.pop_front() {
|
||
self.entries.remove(&victim);
|
||
}
|
||
}
|
||
self.order.push_back(key.clone());
|
||
}
|
||
self.entries.insert(key, layer);
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.entries.len()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.entries.is_empty()
|
||
}
|
||
}
|
||
|
||
/// One derived cell's packed wire fields — the per-cell output of the window
|
||
/// loop body, shared between the serial and parallel builders (T-1151) so the
|
||
/// packing logic can never drift between them.
|
||
struct WindowCell {
|
||
morphology: u8,
|
||
elev_q: u8,
|
||
temp_dc: i16,
|
||
moisture_q: u8,
|
||
vegetation: u8,
|
||
glaciation: u8,
|
||
}
|
||
|
||
/// Derive one window cell at `(row, col)` and pack its wire fields. Pure
|
||
/// (D-227) — the whole reason row-chunked `par_iter` (T-1151) is safe: every
|
||
/// cell is an independent function of its own world-metre position, nothing
|
||
/// shared mutably.
|
||
///
|
||
/// `granularity` (T-1150, widened to [`WindowGranularity`] T-1152) selects
|
||
/// BOTH the metre spacing between cells ([`WindowGranularity::spacing_m`])
|
||
/// AND the derivation function: finer-than-district rungs (`District`,
|
||
/// `Quarter`) call `derive_at_metres` (full derivation, `invent_primitives`
|
||
/// included) exactly as T-1150 shipped; `Region` calls
|
||
/// [`crate::atlas::district_profile::derive_orbital_at_metres`] instead — the
|
||
/// design doc §2/§4 orbital row's region-baseline-blend-only path, no
|
||
/// `invent_primitives` call. This is the ONE place the two derivation
|
||
/// functions fork based on rung — everything else in the window-building
|
||
/// pipeline (`scatter_row`, the six-array packing, the cache/echo plumbing)
|
||
/// is identical regardless of which function ran, because both produce a
|
||
/// `DistrictProfile` and this function's WindowCell-packing tail (below) is
|
||
/// shared.
|
||
///
|
||
/// `half_cells` is HALF the cell-grid side (`side / 2`, already in the
|
||
/// caller's cell units, not districts), so `center` (a `DistrictPos`, always
|
||
/// district-scale) is converted to a world-metre origin once by the caller
|
||
/// and offset here in `spacing_m()` units — this is what makes the quarter
|
||
/// grid cover the SAME world rect as the district grid at 4x the cell
|
||
/// density (design doc §2 Option B), and what makes a region-granularity
|
||
/// window cover a proportionally larger world rect at 1/100x the cell
|
||
/// density (D-243: `DISTRICTS_PER_REGION = 100`).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn derive_window_cell(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
center_world_m: (f64, f64),
|
||
half_cells: i32,
|
||
granularity: WindowGranularity,
|
||
min_wavelength_m: f64,
|
||
row: i32,
|
||
col: i32,
|
||
nearby_courses: &[InventedCourse],
|
||
) -> WindowCell {
|
||
// Row 0 = northmost, matching aliveness_probe's render_window_panels
|
||
// (derive_at_metres maps negative wy to negative lat_frac = north).
|
||
let step_m = granularity.spacing_m();
|
||
let wx = center_world_m.0 + (col - half_cells) as f64 * step_m;
|
||
let wy = center_world_m.1 + (row - half_cells) as f64 * step_m;
|
||
let prof = match granularity {
|
||
WindowGranularity::Region => crate::atlas::district_profile::derive_orbital_at_metres(
|
||
seed, body_id, params, ta, wx, wy, climate,
|
||
),
|
||
WindowGranularity::District | WindowGranularity::Quarter => {
|
||
crate::atlas::district_profile::derive_at_metres(
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
wx,
|
||
wy,
|
||
climate,
|
||
min_wavelength_m,
|
||
nearby_courses,
|
||
)
|
||
}
|
||
};
|
||
WindowCell {
|
||
morphology: prof.morphology_zone as u8,
|
||
elev_q: prof.elev_q.clamp(0, 100) as u8,
|
||
temp_dc: match prof.temperature_c {
|
||
Some(t) => {
|
||
((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16
|
||
}
|
||
None => REGION_TEMP_NONE_DC,
|
||
},
|
||
moisture_q: prof.moisture_q.clamp(0, 100) as u8,
|
||
vegetation: prof.vegetation_class as u8,
|
||
glaciation: prof.glaciation_grade as u8,
|
||
}
|
||
}
|
||
|
||
/// Scatter a computed row of [`WindowCell`]s into the six flat output arrays
|
||
/// at row-major offset `row * side`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn scatter_row(
|
||
row_cells: &[WindowCell],
|
||
row: i32,
|
||
side: i32,
|
||
morphology: &mut [u8],
|
||
elev_q: &mut [u8],
|
||
temp_dc: &mut [i16],
|
||
moisture_q: &mut [u8],
|
||
vegetation: &mut [u8],
|
||
glaciation: &mut [u8],
|
||
) {
|
||
let base = (row * side) as usize;
|
||
for (col, cell) in row_cells.iter().enumerate() {
|
||
let i = base + col;
|
||
morphology[i] = cell.morphology;
|
||
elev_q[i] = cell.elev_q;
|
||
temp_dc[i] = cell.temp_dc;
|
||
moisture_q[i] = cell.moisture_q;
|
||
vegetation[i] = cell.vegetation;
|
||
glaciation[i] = cell.glaciation;
|
||
}
|
||
}
|
||
|
||
/// Resolve `center` (a district-grid position) to its world-metre origin —
|
||
/// shared by both window builders so the district->metres convention can
|
||
/// never drift between them. Mirrors `derive_district`'s own quantization
|
||
/// (`district_profile.rs`) exactly: `dm = DISTRICT_M`, `(dx*dm, dy*dm)`.
|
||
fn center_to_world_m(center: DistrictPos) -> (f64, f64) {
|
||
let dm = DISTRICT_M as f64;
|
||
(center.0 as f64 * dm, center.1 as f64 * dm)
|
||
}
|
||
|
||
/// Peak Stage-B course amplitude never exceeds this fraction of an edge's
|
||
/// chord (mirrors `river_course::STAGE_B_PEAK_FRACTION_OF_CHORD` — kept as an
|
||
/// independent constant here, not a re-export, so the culling inflation and
|
||
/// the actual amplitude cap can never silently decouple through a shared
|
||
/// mutable import path; a `const _: () = assert!(...)` below pins the two
|
||
/// values equal). Used to inflate an edge's chord bounding box before the
|
||
/// window-intersection cull (Ruling 3h: "amplitude-inflated chord bbox").
|
||
const COURSE_BBOX_INFLATION_FRACTION: f64 = 0.08;
|
||
const _: () = assert!(
|
||
(COURSE_BBOX_INFLATION_FRACTION * 1_000_000.0) as i64
|
||
== (crate::atlas::river_course::STAGE_B_PEAK_FRACTION_OF_CHORD * 1_000_000.0) as i64
|
||
);
|
||
|
||
/// The window's world-metre rect, `(x0, y0, x1, y1)` — the SAME convention
|
||
/// [`derive_window_cell`] uses to place cells: `step = granularity.spacing_m()`,
|
||
/// `[center_world_m - half*step, center_world_m + (side-half)*step)` on each
|
||
/// axis. Shared by [`invent_courses_near_window`] and [`crop_courses_for_wire`]
|
||
/// so the rect can never drift between the two.
|
||
fn window_world_rect(
|
||
center_world_m: (f64, f64),
|
||
half_cells: i32,
|
||
side: i32,
|
||
step_m: f64,
|
||
) -> (f64, f64, f64, f64) {
|
||
(
|
||
center_world_m.0 - half_cells as f64 * step_m,
|
||
center_world_m.1 - half_cells as f64 * step_m,
|
||
center_world_m.0 + (side - half_cells) as f64 * step_m,
|
||
center_world_m.1 + (side - half_cells) as f64 * step_m,
|
||
)
|
||
}
|
||
|
||
/// Invent every river course whose amplitude-inflated chord bounding box
|
||
/// intersects this window (T-1170 A2, Ruling 1b/3h/4b) — the FULL-precision
|
||
/// [`InventedCourse`] list, NOT yet cropped to the window or converted to the
|
||
/// wire [`RiverCourse`] shape. This is the single source both consumers read
|
||
/// from: [`derive_window_cell`]'s per-cell riparian test (T-1168, Ruling 4b:
|
||
/// "in the window path, T-1170's already-invented courses") and
|
||
/// [`crop_courses_for_wire`]'s wire packing — computed ONCE per window,
|
||
/// before the per-cell derive loop, rather than twice or per-cell.
|
||
///
|
||
/// Pure function of `(seed, body, river_network, window rect, granularity,
|
||
/// min_wavelength_m)` — independent of whether the caller derives cells
|
||
/// serially or in parallel, which is why both [`build_district_window_layer`]
|
||
/// and its `#[cfg(test)]` serial twin call this SAME function.
|
||
///
|
||
/// Region granularity draws courses via the whole-body skeleton path (Ruling
|
||
/// 5a — the rung-truncated course degenerates to the straight chord at
|
||
/// Region spacing, so the skeleton dots/chords ARE the course there). No
|
||
/// windowed course invention at Region — an empty result here is correct,
|
||
/// not a gap.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn invent_courses_near_window(
|
||
seed: SeedChain,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
river_network: &RiverNetwork,
|
||
window_rect: (f64, f64, f64, f64),
|
||
granularity: WindowGranularity,
|
||
min_wavelength_m: f64,
|
||
) -> Vec<InventedCourse> {
|
||
if granularity == WindowGranularity::Region {
|
||
return Vec::new();
|
||
}
|
||
let step_m = granularity.spacing_m();
|
||
let (win_x0, win_y0, win_x1, win_y1) = window_rect;
|
||
|
||
let edges = river_course::build_edges(river_network);
|
||
let mut courses = Vec::new();
|
||
for edge in &edges {
|
||
let anchor_a = crate::atlas::district_profile::pixel_to_world_m(
|
||
edge.upstream.1 as f64,
|
||
edge.upstream.0 as f64,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
let anchor_b = crate::atlas::district_profile::pixel_to_world_m(
|
||
edge.downstream.1 as f64,
|
||
edge.downstream.0 as f64,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
let chord_m =
|
||
((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt();
|
||
let inflate_m = chord_m * COURSE_BBOX_INFLATION_FRACTION;
|
||
let (bx0, bx1) = (
|
||
anchor_a.0.min(anchor_b.0) - inflate_m,
|
||
anchor_a.0.max(anchor_b.0) + inflate_m,
|
||
);
|
||
let (by0, by1) = (
|
||
anchor_a.1.min(anchor_b.1) - inflate_m,
|
||
anchor_a.1.max(anchor_b.1) + inflate_m,
|
||
);
|
||
// Bbox-vs-window intersection cull — most edges cull to zero for any
|
||
// given window (Ruling 4b's "most cells cull to zero edges" applies
|
||
// symmetrically here: most EDGES cull out of any one window).
|
||
if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 {
|
||
continue;
|
||
}
|
||
|
||
courses.push(river_course::invent_course(
|
||
seed,
|
||
edge,
|
||
ta,
|
||
params,
|
||
step_m,
|
||
min_wavelength_m,
|
||
));
|
||
}
|
||
courses
|
||
}
|
||
|
||
/// Crop the window's already-invented courses ([`invent_courses_near_window`])
|
||
/// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station
|
||
/// beyond each edge, terminus resolution (A3, Ruling 3e/3f).
|
||
///
|
||
/// `station_spacing_m` is the rung's own cell spacing (`granularity.spacing_m()`
|
||
/// — District 2,048 m / Quarter 512 m) — threaded to [`resolve_mouth_terminus`]'s
|
||
/// land-at-final-anchor probe, which extends "one cell length" (Ruling 3e's own
|
||
/// words), not one Stage-B segment length (Tyre, PR #197 review issue 1).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn crop_courses_for_wire(
|
||
invented: &[InventedCourse],
|
||
window_rect: (f64, f64, f64, f64),
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
min_wavelength_m: f64,
|
||
station_spacing_m: f64,
|
||
) -> Vec<RiverCourse> {
|
||
invented
|
||
.iter()
|
||
.filter_map(|course| {
|
||
crop_course_to_window(
|
||
course,
|
||
window_rect,
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
min_wavelength_m,
|
||
station_spacing_m,
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Crop an [`InventedCourse`]'s full-edge point list to `window_rect` (+ one
|
||
/// station beyond each edge, Ruling 3h) and resolve its wire [`CourseTerminus`]
|
||
/// (A3, Ruling 3e/3f). Returns `None` when the course has zero points inside
|
||
/// (or adjacent to) the window — the caller's cull is a cheap bbox pre-filter,
|
||
/// this is the exact per-point check.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn crop_course_to_window(
|
||
course: &InventedCourse,
|
||
window_rect: (f64, f64, f64, f64),
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
min_wavelength_m: f64,
|
||
station_spacing_m: f64,
|
||
) -> Option<RiverCourse> {
|
||
let (x0, y0, x1, y1) = window_rect;
|
||
let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1;
|
||
|
||
let n = course.points.len();
|
||
let mut first_in: Option<usize> = None;
|
||
let mut last_in: Option<usize> = None;
|
||
for (i, p) in course.points.iter().enumerate() {
|
||
if inside(p) {
|
||
first_in.get_or_insert(i);
|
||
last_in = Some(i);
|
||
}
|
||
}
|
||
let (first_in, last_in) = match (first_in, last_in) {
|
||
(Some(a), Some(b)) => (a, b),
|
||
_ => return None, // no point of this course falls inside the window
|
||
};
|
||
// Crop range: one station beyond each edge (Ruling 3h), clamped to the
|
||
// course's own point range.
|
||
let lo = first_in.saturating_sub(1);
|
||
let hi = (last_in + 1).min(n.saturating_sub(1));
|
||
|
||
let points: Vec<(i32, i32)> = course.points[lo..=hi]
|
||
.iter()
|
||
.map(|p| (p.0.round() as i32, p.1.round() as i32))
|
||
.collect();
|
||
|
||
// Terminus resolution (A3, Ruling 3e/3f): only meaningful if the
|
||
// course's TRUE downstream end (the last point of the full, uncropped
|
||
// course) is within this cropped range — otherwise the real terminus
|
||
// lies in a different window and this one just sees a mid-course
|
||
// passthrough.
|
||
let true_end_included = hi == n.saturating_sub(1);
|
||
let terminus = if !true_end_included {
|
||
CourseTerminus::ContinuesBeyondWindow
|
||
} else {
|
||
match course.terminus {
|
||
EdgeTerminusKind::EdgeDrain => CourseTerminus::EdgeDrain,
|
||
EdgeTerminusKind::Interior => CourseTerminus::ContinuesBeyondWindow,
|
||
EdgeTerminusKind::Mouth => {
|
||
match resolve_mouth_terminus(
|
||
course,
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
min_wavelength_m,
|
||
station_spacing_m,
|
||
) {
|
||
Some(mouth_point) => {
|
||
// Replace the cropped course's tail with the resolved
|
||
// mouth point (bisected against the last land
|
||
// station) so the wire polyline ends exactly at the
|
||
// invented-coast crossing, not at the raw upstream
|
||
// anchor placeholder `build_edges` recorded.
|
||
let mut pts = points;
|
||
if let Some(last) = pts.last_mut() {
|
||
*last = (mouth_point.0.round() as i32, mouth_point.1.round() as i32);
|
||
}
|
||
return Some(RiverCourse {
|
||
edge_id: course.edge_id,
|
||
class: course.class,
|
||
points: pts,
|
||
terminus: CourseTerminus::Mouth,
|
||
});
|
||
}
|
||
None => CourseTerminus::None, // degenerate: never found water (Ruling 3e land-at-anchor case)
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
Some(RiverCourse {
|
||
edge_id: course.edge_id,
|
||
class: course.class,
|
||
points,
|
||
terminus,
|
||
})
|
||
}
|
||
|
||
/// Number of bisection iterations for the mouth-terminus search (Ruling 3e,
|
||
/// binding: "fixed 6 iterations").
|
||
const MOUTH_BISECT_ITERATIONS: u32 = 6;
|
||
|
||
/// Walk a `Mouth`-terminus course's stations upstream→downstream, sampling
|
||
/// the SAME rung-consistent morphology water verdict the window's own cells
|
||
/// use (`derive_at_metres(...).morphology_zone` — Ruling 3e, binding: "never
|
||
/// raw `ocean_frac`"). First water station found → bisect against the
|
||
/// previous land station (fixed [`MOUTH_BISECT_ITERATIONS`]) → the resolved
|
||
/// terminus point. If no station (including one D8-direction cell-length
|
||
/// probe past the final anchor) samples water, returns `None` — the
|
||
/// degenerate "drawn coast receded past this edge" case (Ruling 3e), which
|
||
/// the caller renders with no mouth flag.
|
||
///
|
||
/// `station_spacing_m` is the rung's own cell spacing — the probe extends
|
||
/// exactly "one cell length" past the final anchor (Ruling 3e's own words),
|
||
/// in the direction of the final Stage-B segment, but scaled to
|
||
/// `station_spacing_m` rather than that segment's own (possibly much
|
||
/// shorter, near-zero at a taper-to-zero anchor) length.
|
||
fn resolve_mouth_terminus(
|
||
course: &InventedCourse,
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
min_wavelength_m: f64,
|
||
station_spacing_m: f64,
|
||
) -> Option<(f64, f64)> {
|
||
let is_water = |p: (f64, f64)| -> bool {
|
||
// `&[]`: the mouth-termination water-verdict probe has no use for
|
||
// the riparian signal (it only reads `morphology_zone`, never
|
||
// `vegetation_class`) — an empty course slice is a correct, cheap
|
||
// no-op here (T-1168's `nearby_courses` param never affects
|
||
// morphology, only vegetation, so this can never mis-terminate).
|
||
let prof = crate::atlas::district_profile::derive_at_metres(
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
p.0,
|
||
p.1,
|
||
climate,
|
||
min_wavelength_m,
|
||
&[],
|
||
);
|
||
matches!(
|
||
prof.morphology_zone,
|
||
crate::simulation::generator::MorphologyZone::OpenOcean
|
||
| crate::simulation::generator::MorphologyZone::Lake
|
||
)
|
||
};
|
||
|
||
let pts = &course.points;
|
||
if pts.is_empty() {
|
||
return None;
|
||
}
|
||
// Walk upstream -> downstream (points are already stored in that order).
|
||
let mut prev_land = pts[0];
|
||
for &p in pts.iter() {
|
||
if is_water(p) {
|
||
return Some(bisect_to_waterline(prev_land, p, is_water));
|
||
}
|
||
prev_land = p;
|
||
}
|
||
// Final anchor still land: extend ONE CELL LENGTH (`station_spacing_m` —
|
||
// Ruling 3e's own words, "up to one cell length probing", not one
|
||
// Stage-B segment length, which can be much shorter near a
|
||
// taper-to-zero anchor — Tyre, PR #197 review issue 1) along the final
|
||
// segment's own direction, as a single probe.
|
||
if pts.len() >= 2 {
|
||
let a = pts[pts.len() - 2];
|
||
let b = pts[pts.len() - 1];
|
||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||
let len = (dx * dx + dy * dy).sqrt();
|
||
if len > 1e-6 {
|
||
let (ux, uy) = (dx / len, dy / len); // unit direction of the final segment
|
||
let probe = (b.0 + ux * station_spacing_m, b.1 + uy * station_spacing_m);
|
||
if is_water(probe) {
|
||
return Some(bisect_to_waterline(b, probe, is_water));
|
||
}
|
||
}
|
||
}
|
||
None // degenerate: still land — terminate with no mouth flag (caller's job)
|
||
}
|
||
|
||
/// Bisect between a known-land point and a known-water point for
|
||
/// [`MOUTH_BISECT_ITERATIONS`] iterations, returning the point closest to the
|
||
/// water side of the crossing.
|
||
fn bisect_to_waterline(
|
||
land: (f64, f64),
|
||
water: (f64, f64),
|
||
is_water: impl Fn((f64, f64)) -> bool,
|
||
) -> (f64, f64) {
|
||
let mut lo = land; // land
|
||
let mut hi = water; // water
|
||
for _ in 0..MOUTH_BISECT_ITERATIONS {
|
||
let mid = ((lo.0 + hi.0) * 0.5, (lo.1 + hi.1) * 0.5);
|
||
if is_water(mid) {
|
||
hi = mid;
|
||
} else {
|
||
lo = mid;
|
||
}
|
||
}
|
||
hi
|
||
}
|
||
|
||
/// Build a [`DistrictWindowLayer`] by deriving every cell in the window
|
||
/// around `center` (T-1137, extended T-1150). Mirrors
|
||
/// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe
|
||
/// this design promotes to a served layer, D-226 T-1124 amendment §2) — same
|
||
/// row-major indexing, same per-cell derive call.
|
||
///
|
||
/// `n` MUST already be clamped by the caller ([`clamp_window_n`],
|
||
/// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware `WIRE_CAP_CELLS`
|
||
/// ceiling) — this function trusts it verbatim (the clamp is
|
||
/// `handle_atlas_request`'s job, applied once at the wire boundary, not
|
||
/// re-checked on every internal caller per the existing codebase convention
|
||
/// of clamping at the edge).
|
||
///
|
||
/// `n` is always the window extent in DISTRICTS (design doc §2 Option B: "the
|
||
/// window's `n` stays the DISTRICT extent"). The derived cell-grid side
|
||
/// length is [`WindowGranularity::cell_grid_side`] — `n × 4` at `Quarter`,
|
||
/// `n` at `District`, `round(n / 100)` at `Region` (T-1152, D-243's
|
||
/// `DISTRICTS_PER_REGION = 100`; see that method's doc for the full
|
||
/// finer-multiplies/coarser-divides rationale). Full reclassification at the
|
||
/// rung's own spacing (`derive_at_metres`/`derive_orbital_at_metres` per
|
||
/// [`derive_window_cell`]'s dispatch), never a coarser-cell interpolation.
|
||
///
|
||
/// **Row-chunked `par_iter` (T-1151):** each cell is a pure function of its
|
||
/// own position (D-227), so rows can derive in parallel with no shared
|
||
/// mutable state. Chunking by ROW (not per-cell) amortizes Rayon's own
|
||
/// task-dispatch overhead against the ~1.2 µs/cell derive cost (design doc
|
||
/// §7: naive per-cell parallelization risks the dispatch overhead itself
|
||
/// costing more than the work) — one Rayon task per row means `side` tasks of
|
||
/// `side` cells each, not `side²` tasks of one cell each. This applies
|
||
/// unchanged to the `Region` rung (T-1152 step 4: "progressive capped-density
|
||
/// tiling on the SAME carrier ... No new message shape") — the SAME row-chunked
|
||
/// parallel loop, cache, and coalescing machinery serve every rung.
|
||
/// [`build_district_window_layer_serial`] is kept alongside this as the
|
||
/// golden-comparison baseline (T-1151 acceptance: bit-identical serial vs.
|
||
/// parallel output, exact row-major array ordering preserved either way).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn build_district_window_layer(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
river_network: &RiverNetwork,
|
||
center: DistrictPos,
|
||
n: u32,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
granularity: WindowGranularity,
|
||
min_wl_m: u32,
|
||
) -> DistrictWindowLayer {
|
||
use rayon::prelude::*;
|
||
|
||
let side = granularity.cell_grid_side(n);
|
||
let half = side / 2;
|
||
let min_wavelength_m = min_wl_m as f64;
|
||
let center_world_m = center_to_world_m(center);
|
||
let cells = (side * side) as usize;
|
||
let mut morphology = vec![0u8; cells];
|
||
let mut elev_q = vec![0u8; cells];
|
||
let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells];
|
||
let mut moisture_q = vec![0u8; cells];
|
||
let mut vegetation = vec![0u8; cells];
|
||
let mut glaciation = vec![0u8; cells];
|
||
|
||
// T-1170 A2/T-1168 A5: invent this window's river courses ONCE, before
|
||
// the per-cell derive loop — this is the single source both the per-cell
|
||
// riparian test (T-1168, threaded into `derive_window_cell` below) and
|
||
// the wire course packing (crop step, after the loop) read from. Doing
|
||
// this first (not per-cell, not twice) is what keeps the window-cost
|
||
// delta close to the Discipline item 2 ~5% budget.
|
||
let step_m = granularity.spacing_m();
|
||
let window_rect = window_world_rect(center_world_m, half, side, step_m);
|
||
let invented_courses = invent_courses_near_window(
|
||
seed,
|
||
params,
|
||
ta,
|
||
river_network,
|
||
window_rect,
|
||
granularity,
|
||
min_wavelength_m,
|
||
);
|
||
|
||
// One Rayon task per row: derive_window_cell(row, ..) for every col, then
|
||
// scatter that row's results into the flat arrays. Row order in the
|
||
// output collection is preserved by `par_iter` (it yields in index
|
||
// order), so the scatter below reproduces the exact row-major layout the
|
||
// serial loop produces.
|
||
let rows: Vec<Vec<WindowCell>> = (0..side)
|
||
.into_par_iter()
|
||
.map(|row| {
|
||
(0..side)
|
||
.map(|col| {
|
||
derive_window_cell(
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
center_world_m,
|
||
half,
|
||
granularity,
|
||
min_wavelength_m,
|
||
row,
|
||
col,
|
||
&invented_courses,
|
||
)
|
||
})
|
||
.collect()
|
||
})
|
||
.collect();
|
||
|
||
for (row, row_cells) in rows.into_iter().enumerate() {
|
||
scatter_row(
|
||
&row_cells,
|
||
row as i32,
|
||
side,
|
||
&mut morphology,
|
||
&mut elev_q,
|
||
&mut temp_dc,
|
||
&mut moisture_q,
|
||
&mut vegetation,
|
||
&mut glaciation,
|
||
);
|
||
}
|
||
|
||
let courses = crop_courses_for_wire(
|
||
&invented_courses,
|
||
window_rect,
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
min_wavelength_m,
|
||
step_m,
|
||
);
|
||
|
||
DistrictWindowLayer {
|
||
center,
|
||
n,
|
||
granularity_v2: granularity,
|
||
min_wl_m,
|
||
morphology,
|
||
elev_q,
|
||
temp_dc,
|
||
moisture_q,
|
||
vegetation,
|
||
glaciation,
|
||
courses,
|
||
}
|
||
}
|
||
|
||
/// Serial twin of [`build_district_window_layer`] (T-1151) — the pre-parallel
|
||
/// row/col double loop, kept ONLY as the golden-comparison baseline for the
|
||
/// bit-identical serial-vs-parallel test. Not used by production callers.
|
||
#[cfg(test)]
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_district_window_layer_serial(
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &crate::atlas::district_profile::BodyParams,
|
||
ta: &crate::atlas::features::TerrainAnalysis,
|
||
river_network: &RiverNetwork,
|
||
center: DistrictPos,
|
||
n: u32,
|
||
climate: &crate::atlas::district_profile::ClimateConstants,
|
||
granularity: WindowGranularity,
|
||
min_wl_m: u32,
|
||
) -> DistrictWindowLayer {
|
||
let side = granularity.cell_grid_side(n);
|
||
let half = side / 2;
|
||
let min_wavelength_m = min_wl_m as f64;
|
||
let center_world_m = center_to_world_m(center);
|
||
let cells = (side * side) as usize;
|
||
let mut morphology = vec![0u8; cells];
|
||
let mut elev_q = vec![0u8; cells];
|
||
let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells];
|
||
let mut moisture_q = vec![0u8; cells];
|
||
let mut vegetation = vec![0u8; cells];
|
||
let mut glaciation = vec![0u8; cells];
|
||
let step_m = granularity.spacing_m();
|
||
let window_rect = window_world_rect(center_world_m, half, side, step_m);
|
||
let invented_courses = invent_courses_near_window(
|
||
seed,
|
||
params,
|
||
ta,
|
||
river_network,
|
||
window_rect,
|
||
granularity,
|
||
min_wavelength_m,
|
||
);
|
||
for row in 0..side {
|
||
let row_cells: Vec<WindowCell> = (0..side)
|
||
.map(|col| {
|
||
derive_window_cell(
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
center_world_m,
|
||
half,
|
||
granularity,
|
||
min_wavelength_m,
|
||
row,
|
||
col,
|
||
&invented_courses,
|
||
)
|
||
})
|
||
.collect();
|
||
scatter_row(
|
||
&row_cells,
|
||
row,
|
||
side,
|
||
&mut morphology,
|
||
&mut elev_q,
|
||
&mut temp_dc,
|
||
&mut moisture_q,
|
||
&mut vegetation,
|
||
&mut glaciation,
|
||
);
|
||
}
|
||
let courses = crop_courses_for_wire(
|
||
&invented_courses,
|
||
window_rect,
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
min_wavelength_m,
|
||
step_m,
|
||
);
|
||
DistrictWindowLayer {
|
||
center,
|
||
n,
|
||
granularity_v2: granularity,
|
||
min_wl_m,
|
||
morphology,
|
||
elev_q,
|
||
temp_dc,
|
||
moisture_q,
|
||
vegetation,
|
||
glaciation,
|
||
courses,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// QuarterFootprintLayer (D-226 T-1112 amendment, T-1119)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-settlement aggregate over one quarter's 4×4 `BlockSkeleton` grid, for
|
||
/// the Atlas quarter-footprint overlay (D-226 T-1112 amendment §1). Five
|
||
/// scalar fields earn their place per the amendment's hard ceiling (§2): no
|
||
/// per-block zoning/street/tag detail ever reaches the wire, and no
|
||
/// chunk/tile/voxel data is touched.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct QuarterFootprintEntry {
|
||
pub city_id: u64,
|
||
/// Basis-point mean of `BlockSkeleton.density_pct` across the 16 blocks
|
||
/// (integer division, D-010 — no `f32` on the wire).
|
||
pub density_avg_pct: u8,
|
||
/// Mode `DistrictType` across the 16 blocks; ties resolve to the lowest
|
||
/// declaration-order variant (the `Ord` derive on `DistrictType`, T-994).
|
||
pub dominant_district_type: DistrictType,
|
||
/// Mode `ZoningType` across the 16 blocks; same tie rule (the `Ord`
|
||
/// derive added on `ZoningType` for this ticket, T-1119).
|
||
pub dominant_zoning: ZoningType,
|
||
/// Count of blocks with `landmark: Some(_)` across the 16 blocks (max 16).
|
||
/// Tooltip/sidebar-only per the D-226(d) ceiling — never a map-visible
|
||
/// channel (§2).
|
||
pub landmark_count: u8,
|
||
/// `QuarterSkeleton.corridors.len()`, clamped to `u8`. Tooltip/sidebar-only,
|
||
/// same ceiling as `landmark_count`.
|
||
pub corridor_count: u8,
|
||
}
|
||
|
||
/// The quarter-footprint overlay for one body (D-226 T-1112 amendment §1),
|
||
/// keyed by `city_id` — a quarter carries no independent spatial position of
|
||
/// its own (`QuarterId` is a content-addressable hash, not a coordinate), so
|
||
/// the layer anchors at the existing L3 settlement position client-side and
|
||
/// this map only needs to answer "does this settlement have quarter data, and
|
||
/// if so what does it aggregate to".
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct QuarterFootprintLayer {
|
||
/// `BTreeMap` for D-010 determinism, matching `RegionGridLayer`'s and the
|
||
/// source `QuarterWorldState.block_tags`' own `BTreeMap` precedent.
|
||
pub entries: std::collections::BTreeMap<u64, QuarterFootprintEntry>,
|
||
}
|
||
|
||
/// Aggregate one quarter's 16 `BlockSkeleton`s into a [`QuarterFootprintEntry`]
|
||
/// for `city_id`.
|
||
fn aggregate_quarter_footprint(
|
||
city_id: u64,
|
||
skeleton: &crate::simulation::generator::QuarterSkeleton,
|
||
) -> QuarterFootprintEntry {
|
||
let blocks: Vec<&crate::simulation::generator::BlockSkeleton> =
|
||
skeleton.blocks.iter().flatten().collect();
|
||
let n = blocks.len() as u32; // always 16 (the fixed 4×4 grid) — computed
|
||
// rather than hardcoded so the mean formula
|
||
// stays correct if the grid shape ever changes.
|
||
|
||
let density_sum: u32 = blocks.iter().map(|b| b.density_pct as u32).sum();
|
||
let density_avg_pct = if n == 0 { 0 } else { (density_sum / n) as u8 };
|
||
|
||
let dominant_district_type = mode_by_declaration_order(blocks.iter().map(|b| &b.district_type))
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let dominant_zoning = mode_by_declaration_order(blocks.iter().map(|b| &b.zoning))
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
|
||
let landmark_count = blocks.iter().filter(|b| b.landmark.is_some()).count() as u8;
|
||
let corridor_count = skeleton.corridors.len().min(u8::MAX as usize) as u8;
|
||
|
||
QuarterFootprintEntry {
|
||
city_id,
|
||
density_avg_pct,
|
||
dominant_district_type,
|
||
dominant_zoning,
|
||
landmark_count,
|
||
corridor_count,
|
||
}
|
||
}
|
||
|
||
/// Mode of an `Ord` value over an iterator, tie-broken by lowest declaration
|
||
/// order (i.e. the `Ord`-smallest value among the tied-for-max-count values).
|
||
/// `None` for an empty iterator.
|
||
///
|
||
/// **Not** `counts.into_iter().max_by_key(...)`: `Iterator::max_by_key`
|
||
/// returns the *last* maximum on a tie (documented behaviour), which is the
|
||
/// opposite of what's needed here. `BTreeMap` iterates keys in ascending
|
||
/// `Ord` order (= declaration order for these enums), so walking forward and
|
||
/// only replacing the running best on a *strictly greater* count keeps the
|
||
/// first-seen — i.e. lowest-declaration-order — winner on every tie.
|
||
fn mode_by_declaration_order<'a, T: Ord + 'a>(
|
||
values: impl Iterator<Item = &'a T>,
|
||
) -> Option<&'a T> {
|
||
let mut counts: std::collections::BTreeMap<&'a T, u32> = std::collections::BTreeMap::new();
|
||
for v in values {
|
||
*counts.entry(v).or_insert(0) += 1;
|
||
}
|
||
let mut best: Option<(&'a T, u32)> = None;
|
||
for (v, count) in counts {
|
||
match best {
|
||
Some((_, best_count)) if count <= best_count => {}
|
||
_ => best = Some((v, count)),
|
||
}
|
||
}
|
||
best.map(|(v, _)| v)
|
||
}
|
||
|
||
/// Build the [`QuarterFootprintLayer`] from a body's cached state (T-1119).
|
||
/// Returns `None` when the Quarter-skeleton layer has not run for any
|
||
/// settlement (empty `state.quarters`).
|
||
///
|
||
/// `state.quarters` carries no independent spatial position — the only
|
||
/// spatial anchor a quarter has is the `city_id` it was generated for
|
||
/// (D-226 T-1112 amendment §1). So this recomputes the same deterministic
|
||
/// `QuarterId` derivation the L3→L4 dispatch path uses
|
||
/// (`SeedChain::for_body(world_seed, body_id).derive(SeedDomain::Layer4Quarter,
|
||
/// city_id).seed()`, `plugin.rs::build_skeleton_work_item`) for every placed
|
||
/// settlement and looks it up in `state.quarters`. A placement whose derived
|
||
/// id isn't found (skeleton generation is async, dispatched at `Low` priority
|
||
/// after the body's `Ready` snapshot is already cached — `plugin.rs`) is
|
||
/// skipped, not defaulted: an absent quarter is not a zero-footprint quarter.
|
||
pub fn build_quarter_footprint_layer(
|
||
state: &BodyWorldState,
|
||
world_seed: u64,
|
||
) -> Option<QuarterFootprintLayer> {
|
||
if state.quarters.is_empty() {
|
||
return None;
|
||
}
|
||
let body_chain = SeedChain::for_body(world_seed, &state.body_id);
|
||
let mut entries = std::collections::BTreeMap::new();
|
||
for placement in &state.placements {
|
||
let quarter_id = body_chain
|
||
.derive(SeedDomain::Layer4Quarter, placement.city_id)
|
||
.seed();
|
||
if let Some(quarter_state) = state.quarters.get(&quarter_id) {
|
||
entries.insert(
|
||
placement.city_id,
|
||
aggregate_quarter_footprint(placement.city_id, &quarter_state.skeleton),
|
||
);
|
||
}
|
||
}
|
||
Some(QuarterFootprintLayer { entries })
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RoadGraphLayer (T-960 §1, T-1038)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One node in the [`RoadGraphLayer`] overlay — a settlement junction or a
|
||
/// waypoint. Trimmed from the internal [`crate::atlas::road_graph::RoadNode`]:
|
||
/// `degree` and `parent_edge` are internal bookkeeping a planetary-map overlay
|
||
/// doesn't need (degree is trivially re-derivable client-side by counting
|
||
/// edges per node index if a renderer wants junction highlighting).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadGraphNode {
|
||
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
|
||
/// space as `Layer1Output` attractors/rivers and `SettlementLayer` positions.
|
||
pub position: (u16, u16),
|
||
pub kind: RoadNodeKind,
|
||
/// The settlement's `city_id` (cross-references `SettlementLayer`), or
|
||
/// `None` for a waypoint.
|
||
pub city_id: Option<u64>,
|
||
}
|
||
|
||
/// One edge in the [`RoadGraphLayer`] overlay — a routed road or rail segment.
|
||
/// Trimmed from [`crate::atlas::road_graph::RoadEdge`]: `length_cells` is an
|
||
/// internal A* routing-grid measure with no meaning outside that grid's scale
|
||
/// (the polyline `path` is what an overlay actually draws).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadGraphEdge {
|
||
/// `nodes` indices of the endpoint settlements (`from < to`).
|
||
pub from: usize,
|
||
pub to: usize,
|
||
/// Routed polyline in working-heightmap-grid coordinates `(row, col)`.
|
||
pub path: Vec<(u16, u16)>,
|
||
pub maintenance: MaintenanceAuthority,
|
||
/// `true` if this edge is a railroad; `false` is a road.
|
||
pub is_rail: bool,
|
||
/// Joined `systems.db` named-route id, if any (empty pool today — D-223).
|
||
pub named_route_id: Option<String>,
|
||
}
|
||
|
||
/// The inter-settlement road/rail graph, trimmed for the Atlas planetary-map
|
||
/// overlay (T-960 §1, D-211, T-1038). See [`RoadGraphNode`]/[`RoadGraphEdge`]
|
||
/// for what was dropped from the internal `RoadGraph`.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct RoadGraphLayer {
|
||
pub nodes: Vec<RoadGraphNode>,
|
||
pub edges: Vec<RoadGraphEdge>,
|
||
}
|
||
|
||
/// Build the [`RoadGraphLayer`] from a body's cached state (T-960 §1).
|
||
/// Returns `None` when the RoadGraph layer has not run, which coincides
|
||
/// exactly with "no settlements placed" (`build_road_graph` returns an empty
|
||
/// graph for zero placements, and every placement yields at least one node).
|
||
pub fn build_road_graph_layer(state: &BodyWorldState) -> Option<RoadGraphLayer> {
|
||
if state.road_graph.nodes.is_empty() {
|
||
return None;
|
||
}
|
||
let nodes = state
|
||
.road_graph
|
||
.nodes
|
||
.iter()
|
||
.map(|n| RoadGraphNode {
|
||
position: n.position,
|
||
kind: n.kind,
|
||
city_id: n.city_id,
|
||
})
|
||
.collect();
|
||
let edges = state
|
||
.road_graph
|
||
.edges
|
||
.iter()
|
||
.map(|e| RoadGraphEdge {
|
||
from: e.from,
|
||
to: e.to,
|
||
path: e.path.clone(),
|
||
maintenance: e.maintenance,
|
||
is_rail: e.is_rail,
|
||
named_route_id: e.named_route_id.clone(),
|
||
})
|
||
.collect();
|
||
Some(RoadGraphLayer { nodes, edges })
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SettlementLayer (T-960 §2, #955)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Coarse settlement size class for the Atlas overlay (T-960 §2), derived from
|
||
/// raw population using the same Tier A/B population cutoffs the D-211
|
||
/// placement pipeline already uses (`attractor_matching::match_cities`):
|
||
/// Tier A (≥ 1,000,000 or `NameLocked`) settlements are `Major`, Tier B
|
||
/// (50,000–999,999) are `Standard`, and everything else (Tier C / synthetic
|
||
/// overflow) is `Minor`. A display bucket, not new simulation truth.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum SettlementSizeClass {
|
||
Major,
|
||
Standard,
|
||
Minor,
|
||
}
|
||
|
||
impl SettlementSizeClass {
|
||
/// Bucket a raw population using the D-211 Tier A/B cutoffs.
|
||
pub fn from_population(population: i64) -> Self {
|
||
if population >= 1_000_000 {
|
||
SettlementSizeClass::Major
|
||
} else if population >= 50_000 {
|
||
SettlementSizeClass::Standard
|
||
} else {
|
||
SettlementSizeClass::Minor
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One placed settlement in the [`SettlementLayer`] overlay (T-960 §2).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct SettlementEntry {
|
||
pub city_id: u64,
|
||
pub name: String,
|
||
/// Position in working-heightmap-grid coordinates `(row, col)` — the same
|
||
/// space as `Layer1Output` attractors/rivers (T-960 §2: match the
|
||
/// coordinate convention layer1 features already use so the client
|
||
/// transforms identically).
|
||
pub position: (u16, u16),
|
||
pub size_class: SettlementSizeClass,
|
||
/// Authored `atlas_city_names.kind == 'capital'` (not population-derived).
|
||
pub is_capital: bool,
|
||
/// Cheap derived flag: `true` if the settlement's anchoring attractor is
|
||
/// water-adjacent (`CoastalAccess` / `RiverMouth` / `LakeShore`).
|
||
pub is_port: bool,
|
||
}
|
||
|
||
/// The settlement-placement overlay for one body (T-960 §2, #955, D-211).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct SettlementLayer {
|
||
pub settlements: Vec<SettlementEntry>,
|
||
}
|
||
|
||
/// `true` for the water-adjacent attractor types a settlement counts as a
|
||
/// "port" for cheaply (T-960 §2). No "foothold" flag: unlike `is_port`, there
|
||
/// is no existing concept in the placement data this could derive from
|
||
/// without inventing new business logic — left out (see the T-960 report).
|
||
fn is_port_attractor(at: AttractorType) -> bool {
|
||
matches!(
|
||
at,
|
||
AttractorType::CoastalAccess | AttractorType::RiverMouth | AttractorType::LakeShore
|
||
)
|
||
}
|
||
|
||
/// Build the [`SettlementLayer`] from a body's cached state (T-960 §2).
|
||
/// Returns `None` when the Settlement layer has not placed any city yet.
|
||
pub fn build_settlement_layer(state: &BodyWorldState) -> Option<SettlementLayer> {
|
||
if state.placements.is_empty() {
|
||
return None;
|
||
}
|
||
let settlements = state
|
||
.placements
|
||
.iter()
|
||
.map(|p| SettlementEntry {
|
||
city_id: p.city_id,
|
||
name: p.name.clone(),
|
||
position: p.position,
|
||
size_class: SettlementSizeClass::from_population(p.population),
|
||
is_capital: p.is_capital,
|
||
is_port: is_port_attractor(p.attractor_type),
|
||
})
|
||
.collect();
|
||
Some(SettlementLayer { settlements })
|
||
}
|
||
|
||
/// Normalize a wire-supplied district-window centre against the body's
|
||
/// physical geometry (T-1142 — a letterbox-click bug sent `window_center`
|
||
/// wildly out of a body's valid range; the server accepted it, derived
|
||
/// clamped garbage inside `derive_district`, and CACHED that garbage under
|
||
/// the raw un-normalized key). Mirrors `district_profile::derive_district`'s
|
||
/// own forward mapping (`district_profile.rs:1426-1451`) EXACTLY, so a window
|
||
/// centre that survives this normalization derives identically to how
|
||
/// `derive_district` would have resolved it anyway — this function only
|
||
/// closes the gap between "derive_district silently clamps/wraps internally"
|
||
/// and "the SERVING path (cache key, coalescing key) saw the raw value".
|
||
///
|
||
/// **Column (longitude) wraps** — `rem_euclid` against the body's
|
||
/// circumference in districts, mirroring the forward map's
|
||
/// `(wx / circumference_m).rem_euclid(1.0)` (longitude is periodic; a click
|
||
/// at column 12276 on a body whose circumference is a few hundred districts
|
||
/// wide is the SAME point as some small in-range column, not garbage).
|
||
///
|
||
/// **Row (latitude) clamps** — to `±half_meridian_districts`, mirroring the
|
||
/// forward map's `(wy / meridian_m).clamp(-0.5, 0.5)` (latitude is NOT
|
||
/// periodic; it terminates at the poles, so out-of-range rows collapse to the
|
||
/// nearest pole rather than wrapping — same asymmetry `derive_district`
|
||
/// itself already encodes).
|
||
///
|
||
/// Both bounds are derived from `body_radius_km` via the SAME `DISTRICT_M`
|
||
/// (2 048 m, D-243) constant the forward map uses — no independent magic
|
||
/// numbers that could silently drift out of sync with `derive_district`.
|
||
///
|
||
/// **No radius (`body_radius_km` absent/non-positive — tiny test bodies
|
||
/// only, per `BodyParams`'s own doc: "e.g. tiny test bodies"; every real
|
||
/// `systems.db` body row carries a radius):** identity, no wrap/clamp. The
|
||
/// forward map's own no-radius branch has no periodicity concept either (it
|
||
/// clamps the FRACTIONAL PIXEL position directly against the heightmap's
|
||
/// working-grid dimensions, which aren't known at request-serving time — only
|
||
/// inside the Rayon work item once the heightmap is loaded); a real letterbox
|
||
/// click can never hit this branch, so it is out of this fix's scope.
|
||
fn normalize_window_center(params: &BodyParams, center: DistrictPos) -> DistrictPos {
|
||
let (dx, dy) = center;
|
||
match params.body_radius_km {
|
||
Some(r_km) if r_km > 0.0 => {
|
||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||
// Whole districts per full circumference / per half-meridian —
|
||
// rounded (not truncated) so the bound matches the forward map's
|
||
// continuous fraction as closely as an integer district grid can.
|
||
let districts_per_circumference =
|
||
(circumference_m / DISTRICT_M as f64).round().max(1.0) as i32;
|
||
let half_meridian_districts = (meridian_m / DISTRICT_M as f64 / 2.0).round() as i32;
|
||
|
||
let wrapped_dx = dx.rem_euclid(districts_per_circumference);
|
||
let clamped_dy = dy.clamp(-half_meridian_districts, half_meridian_districts);
|
||
(wrapped_dx, clamped_dy)
|
||
}
|
||
// No radius: derive_district's own fallback has no wrap/clamp concept
|
||
// at the DistrictPos level (see the doc above) — identity.
|
||
_ => center,
|
||
}
|
||
}
|
||
|
||
/// Resolve `req`'s district-window query, if any (D-226 T-1124 amendment,
|
||
/// T-1137). Returns `None` immediately when `req.window_center` is absent (no
|
||
/// window requested — the common case, zero cost).
|
||
///
|
||
/// **Independent of the whole-body cache state** (the amendment is explicit:
|
||
/// "the window derivation depends only on `TerrainAnalysis` + `BodyParams`
|
||
/// being resolvable for the body ... not on which whole-body layers the
|
||
/// cascade has cached") — so this runs whether `handle_atlas_request` is
|
||
/// about to take its cache-hit or cache-miss branch, sharing neither's control
|
||
/// flow.
|
||
///
|
||
/// **`window_center` is normalized via [`normalize_window_center`] BEFORE the
|
||
/// `DistrictWindowCache` key AND the `submit_window` coalescing key are built**
|
||
/// (T-1142) — this is why `body_params` is read HERE, unconditionally,
|
||
/// rather than only inside the former miss-branch: normalization needs
|
||
/// `body_radius_km` to compute the wrap/clamp bounds, and it must happen
|
||
/// before either key exists, or an insane request and its sane normalized
|
||
/// twin would land in different cache entries (exactly the bug this fix
|
||
/// closes — a garbage `window_center` was cached standalone instead of
|
||
/// collapsing onto its valid twin). The coalescing key itself is
|
||
/// `(ConnectionId, body_id)` — it never carried `center`, so coalescing
|
||
/// was never at risk of diverging per-center; normalizing before
|
||
/// `submit_window` matters only so the work item DERIVES (and echoes) the
|
||
/// canonical center. The one-time cost
|
||
/// (a single indexed `bodies` row read) is paid on every window request now,
|
||
/// not just on a cache miss — a request whose normalized center hits the
|
||
/// cache still needed this read to know WHICH key to check.
|
||
///
|
||
/// Cache hit (`(body_id, normalized_center, n)` already in `window_cache`) →
|
||
/// `Some` immediately, no queue submission (D-227: a previously-derived
|
||
/// window for this body+seed is valid forever, no staleness check needed).
|
||
/// Cache miss → submit a `DeriveWindow` work item (queue-based, per the
|
||
/// amendment's binding serving model — never inline here) and return `None`;
|
||
/// the *next* request for this `(body, normalized_center, n)` re-checks the
|
||
/// cache and finds it populated once `drain_generation_completions` has
|
||
/// processed the completion (the existing D-225 poll-and-recheck-cache
|
||
/// pattern every other layer already uses, not a push).
|
||
///
|
||
/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` (or
|
||
/// `DISTRICT_WINDOW_MAX_N_REGION` at `Region` granularity, T-1152) AND the
|
||
/// granularity-aware `WIRE_CAP_CELLS` ceiling here — the ONE place that clamp
|
||
/// is applied; nothing downstream re-checks the wire value. The granularity
|
||
/// itself is resolved via [`resolve_window_granularity_v2`] at the same
|
||
/// boundary (T-1150, widened T-1152 — see that function's doc for the
|
||
/// legacy-`u32`-vs-`window_granularity_v2` precedence rule).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn serve_district_window(
|
||
req: &AtlasLayerRequest,
|
||
window_cache: &mut DistrictWindowCache,
|
||
queue: &GenerationQueue,
|
||
resolver: &BodySourceResolver,
|
||
body_params_reader: Option<&BodyParamsReader>,
|
||
world_seed: u64,
|
||
conn_id: ConnectionId,
|
||
) -> Option<DistrictWindowLayer> {
|
||
let raw_center = req.window_center?;
|
||
let granularity = resolve_window_granularity_v2(req);
|
||
let n = clamp_window_n_v2(req.window_n, granularity);
|
||
// T-1150 design doc §5: quantize BEFORE either the cache key or the
|
||
// DeriveWindow work item sees it — the raw wire value never reaches
|
||
// either (same discipline as window_n's clamp above and
|
||
// normalize_window_center's wrap/clamp below).
|
||
let min_wl_m = quantize_min_wl_m(req.window_min_wl_m);
|
||
|
||
// body_params is needed to normalize the centre BEFORE either cache key
|
||
// exists (T-1142) — read it first, unconditionally (not gated on a cache
|
||
// miss like the former structure). A read failure here can't distinguish
|
||
// "insane vs. sane center" for the key, so it's a hard skip for the whole
|
||
// window (window stays None on this response), same failure posture the
|
||
// former miss-only read already had.
|
||
let Some(reader) = body_params_reader else {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
"district window request: no body_params_reader wired — window stays None"
|
||
);
|
||
return None;
|
||
};
|
||
let body_params = match reader.read_body_params(&req.body_id) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"district window request: body_params read failed — window stays None"
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
let center = normalize_window_center(&body_params, raw_center);
|
||
if center != raw_center {
|
||
tracing::debug!(
|
||
body_id = %req.body_id,
|
||
raw = ?raw_center,
|
||
normalized = ?center,
|
||
"district window request: out-of-range window_center normalized (T-1142)"
|
||
);
|
||
}
|
||
|
||
// T-1150: granularity + min_wl_m are part of the cache key — a
|
||
// granularity-4 request at the same (body, center, n) as a granularity-1
|
||
// request is a DIFFERENT payload and must never alias onto the same slot
|
||
// (design doc §3's aliasing risk, the mandatory regression test below).
|
||
let key: DistrictWindowKey = (req.body_id.clone(), center, n, granularity, min_wl_m);
|
||
if let Some(layer) = window_cache.get(&key) {
|
||
return Some(layer.clone());
|
||
}
|
||
|
||
// Miss — resolve the heightmap and submit a background derive.
|
||
// Read/resolve failures are non-fatal for the window (log + skip): the
|
||
// window simply stays None on this response, same as an unrun whole-body
|
||
// layer, rather than failing the entire AtlasLayerResponse.
|
||
let heightmap_path = match resolver.resolve(&req.body_id) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"district window request: heightmap resolve failed — window stays None"
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
queue.submit_window(
|
||
GenWorkItem::DeriveWindow {
|
||
body_id: req.body_id.clone(),
|
||
conn_id,
|
||
heightmap_path,
|
||
sea_level: DEFAULT_SEA_LEVEL,
|
||
body_seed: SeedChain::for_body(world_seed, &req.body_id),
|
||
body_params: Box::new(body_params),
|
||
center,
|
||
n,
|
||
granularity,
|
||
min_wl_m,
|
||
},
|
||
GenPriority::Immediate,
|
||
);
|
||
None
|
||
}
|
||
|
||
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
|
||
/// `world_seed` derives the body's `SeedChain` for the enqueued analysis.
|
||
///
|
||
/// `city_reader` supplies the body's settlements for Layer-3 placement (#955),
|
||
/// read on a cache miss. `None` (or a read failure) places no cities — the
|
||
/// cascade still runs Layer 1; the body just gets no settlement placements.
|
||
///
|
||
/// `body_params_reader` supplies the body's physical parameters for the
|
||
/// DistrictProfile carrier layer (T-1032, D-239 §1), read on a cache miss.
|
||
/// `None` (or a read failure) passes `body_params: None` to the work item,
|
||
/// causing the cascade to stop at `CascadeLayer::Settlement` (pre-T-1032
|
||
/// behaviour). A successful read passes `Some(Box::new(params))`, enabling
|
||
/// the full `CascadeLayer::DistrictProfile` path.
|
||
///
|
||
/// `window_cache` + `conn_id` serve the optional district-window query
|
||
/// (D-226 T-1124 amendment, T-1137) via [`serve_district_window`] — see that
|
||
/// function for the caching/coalescing model. `conn_id` is used ONLY as the
|
||
/// window request's coalescing key; nothing else in this function is
|
||
/// connection-aware (the D-254 §2 convention this proxy already follows).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn handle_atlas_request(
|
||
req: &AtlasLayerRequest,
|
||
cache: &mut BodyWorldStateCache,
|
||
window_cache: &mut DistrictWindowCache,
|
||
queue: &GenerationQueue,
|
||
resolver: &BodySourceResolver,
|
||
city_reader: Option<&CityContextReader>,
|
||
body_params_reader: Option<&BodyParamsReader>,
|
||
world_seed: u64,
|
||
current_tick: SimTick,
|
||
conn_id: ConnectionId,
|
||
) -> AtlasLayerResponse {
|
||
let district_window = serve_district_window(
|
||
req,
|
||
window_cache,
|
||
queue,
|
||
resolver,
|
||
body_params_reader,
|
||
world_seed,
|
||
conn_id,
|
||
);
|
||
|
||
// Cache hit — serve immediately.
|
||
if let Some(state) = cache.get(&req.body_id, current_tick) {
|
||
let layer1 = Layer1Output {
|
||
body_id: state.body_id.clone(),
|
||
river_network: state.river_network.clone(),
|
||
drainage_basins: state.drainage_basins.clone(),
|
||
attractors: state.attractors.clone(),
|
||
// The cascade ran on the downsampled heightmap, so its dims are the
|
||
// working grid all Layer-1 positions are expressed in (#960).
|
||
grid_w: state.heightmap_width,
|
||
grid_h: state.heightmap_height,
|
||
// survey_basin_dirs is transient — it is aggregated during run_layer1
|
||
// and consumed by derive_all_districts before being stored on
|
||
// BodyWorldState. When reconstructing Layer1Output from the cache for
|
||
// the client response, the per-survey-cell direction is already encoded
|
||
// in DistrictProfile.basin_direction (BodyWorldState.districts) and is
|
||
// not needed again here. Supply an empty map.
|
||
survey_basin_dirs: std::collections::BTreeMap::new(),
|
||
// T-1169: mirrors state.attractors.clone() above — feature_names
|
||
// is stored on BodyWorldState (see cascade::into_body_world_state)
|
||
// and cloned back out here, same reconstruction discipline as
|
||
// every other Layer1Output field on this cache-hit path.
|
||
feature_names: state.feature_names.clone(),
|
||
};
|
||
let district_grid = build_district_grid(state);
|
||
let road_graph = build_road_graph_layer(state);
|
||
let settlements = build_settlement_layer(state);
|
||
let region_grid = build_region_grid(state);
|
||
let quarter_footprints = build_quarter_footprint_layer(state, world_seed);
|
||
return AtlasLayerResponse {
|
||
body_id: req.body_id.clone(),
|
||
status: AtlasLayerStatus::Ready,
|
||
layer1: Some(layer1),
|
||
district_grid,
|
||
road_graph,
|
||
settlements,
|
||
region_grid,
|
||
district_window,
|
||
quarter_footprints,
|
||
};
|
||
}
|
||
|
||
// Miss — resolve the source heightmap and enqueue background analysis.
|
||
match resolver.resolve(&req.body_id) {
|
||
Ok(heightmap_path) => {
|
||
// Pre-resolve this body's settlements + system faction so the Rayon
|
||
// work item stays DB-free (#955/#956, D-225). Read failures are
|
||
// non-fatal: log and fall back (no cities / no faction → frontier).
|
||
let (cities, dominant_faction) = match city_reader {
|
||
Some(reader) => {
|
||
let cities = reader
|
||
.read_body_settlements(&req.body_id)
|
||
.unwrap_or_else(|e| {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"settlement read failed; placing no cities"
|
||
);
|
||
Vec::new()
|
||
});
|
||
let faction = reader
|
||
.read_body_dominant_faction(&req.body_id)
|
||
.unwrap_or_else(|e| {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"dominant_faction read failed; defaulting to frontier"
|
||
);
|
||
None
|
||
});
|
||
(cities, faction)
|
||
}
|
||
None => (Vec::new(), None),
|
||
};
|
||
// Pre-resolve this body's reserved river/mountain name pools so
|
||
// the Rayon work item stays DB-free (T-1169, D-223, same D-225
|
||
// pattern as `cities` above). Read failure is non-fatal: log and
|
||
// fall back to no names (attach_feature_names degrades gracefully
|
||
// — every attractor simply gets no name).
|
||
let (river_names, mountain_names) = match city_reader {
|
||
Some(reader) => match reader.read_body_feature_names(&req.body_id) {
|
||
Ok(rows) => {
|
||
let mut rivers = Vec::new();
|
||
let mut mountains = Vec::new();
|
||
for row in rows {
|
||
match row.feature_type.as_str() {
|
||
"river" => rivers.push(row.name),
|
||
"mountain" => mountains.push(row.name),
|
||
_ => {}
|
||
}
|
||
}
|
||
(rivers, mountains)
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"feature name read failed; attaching no names"
|
||
);
|
||
(Vec::new(), Vec::new())
|
||
}
|
||
},
|
||
None => (Vec::new(), Vec::new()),
|
||
};
|
||
// Pre-resolve body physical params so the Rayon work item stays
|
||
// DB-free (D-225 pattern). Read failures are non-fatal: log and
|
||
// fall back to None (cascade stops at Settlement, pre-T-1032
|
||
// behaviour, rather than aborting the entire analysis).
|
||
let body_params = match body_params_reader {
|
||
Some(reader) => reader
|
||
.read_body_params(&req.body_id)
|
||
.map(|p| Some(Box::new(p)))
|
||
.unwrap_or_else(|e| {
|
||
tracing::warn!(
|
||
body_id = %req.body_id,
|
||
error = %e,
|
||
"body_params read failed; district layer skipped"
|
||
);
|
||
None
|
||
}),
|
||
None => None,
|
||
};
|
||
queue.submit(
|
||
GenWorkItem::AnalyzeBody {
|
||
body_id: req.body_id.clone(),
|
||
heightmap_path,
|
||
sea_level: DEFAULT_SEA_LEVEL,
|
||
body_seed: SeedChain::for_body(world_seed, &req.body_id),
|
||
cities,
|
||
dominant_faction,
|
||
body_params,
|
||
river_names,
|
||
mountain_names,
|
||
},
|
||
GenPriority::Immediate,
|
||
);
|
||
AtlasLayerResponse {
|
||
body_id: req.body_id.clone(),
|
||
status: AtlasLayerStatus::Pending,
|
||
layer1: None,
|
||
district_grid: None,
|
||
road_graph: None,
|
||
settlements: None,
|
||
region_grid: None,
|
||
district_window,
|
||
quarter_footprints: None,
|
||
}
|
||
}
|
||
// Unknown / no terrain → re-requesting won't help.
|
||
Err(SourceResolveError::UnknownBody(_))
|
||
| Err(SourceResolveError::NoTerrainReference { .. }) => AtlasLayerResponse {
|
||
body_id: req.body_id.clone(),
|
||
status: AtlasLayerStatus::NotFound,
|
||
layer1: None,
|
||
district_grid: None,
|
||
road_graph: None,
|
||
settlements: None,
|
||
region_grid: None,
|
||
district_window,
|
||
quarter_footprints: None,
|
||
},
|
||
Err(e) => AtlasLayerResponse {
|
||
body_id: req.body_id.clone(),
|
||
status: AtlasLayerStatus::Error(e.to_string()),
|
||
layer1: None,
|
||
district_grid: None,
|
||
road_graph: None,
|
||
settlements: None,
|
||
region_grid: None,
|
||
district_window,
|
||
quarter_footprints: None,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork, CACHE_CAPACITY};
|
||
use crate::atlas::gen_queue::GenCompletion;
|
||
use rusqlite::Connection;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::time::Duration;
|
||
|
||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||
|
||
#[test]
|
||
fn district_grid_built_from_cached_districts() {
|
||
use crate::atlas::district_profile::{
|
||
DistrictProfile, GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
|
||
};
|
||
use crate::simulation::generator::MorphologyZone;
|
||
let dp = |zone: MorphologyZone, elev: i32| DistrictProfile {
|
||
morphology_zone: zone,
|
||
tectonic_class: TectonicClass::Stable,
|
||
glaciation_grade: GlaciationGrade::None,
|
||
precipitation_class: PrecipitationClass::Arid,
|
||
slope_q: 0,
|
||
elev_q: elev,
|
||
ocean_fraction_q: 0,
|
||
lake_margin_q: 0,
|
||
river_threshold: 200,
|
||
temperature_c: Some(10.0),
|
||
moisture_q: 50,
|
||
vegetation_class: VegetationClass::Barren,
|
||
basin_direction: crate::atlas::scale::BasinDirection::default(),
|
||
};
|
||
let mut state = BodyWorldState {
|
||
body_id: "GJ1c".into(),
|
||
heightmap: vec![],
|
||
heightmap_width: 16,
|
||
heightmap_height: 8,
|
||
sea_level: 0.3,
|
||
river_network: RiverNetwork::default(),
|
||
drainage_basins: vec![],
|
||
attractors: vec![],
|
||
feature_names: vec![],
|
||
placements: vec![],
|
||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||
quarters: std::collections::BTreeMap::new(),
|
||
districts: std::collections::BTreeMap::new(),
|
||
regions: std::collections::BTreeMap::new(),
|
||
last_accessed: 0,
|
||
};
|
||
// 3×2 grid with two distinct zones at the corners.
|
||
state.districts.insert(
|
||
crate::atlas::scale::SurveyCellPos(0, 0),
|
||
dp(MorphologyZone::AlluvialPlain, 10),
|
||
);
|
||
state.districts.insert(
|
||
crate::atlas::scale::SurveyCellPos(2, 1),
|
||
dp(MorphologyZone::Alpine, 90),
|
||
);
|
||
|
||
let grid = build_district_grid(&state).expect("districts present → Some grid");
|
||
assert_eq!((grid.cols, grid.rows), (3, 2));
|
||
assert_eq!(grid.morphology.len(), 6);
|
||
assert_eq!(grid.morphology[0], MorphologyZone::AlluvialPlain as u8);
|
||
assert_eq!(
|
||
grid.morphology[grid.cols as usize + 2],
|
||
MorphologyZone::Alpine as u8
|
||
);
|
||
assert_eq!(grid.elev_q[grid.cols as usize + 2], 90);
|
||
|
||
// Empty districts → None (DistrictProfile layer hasn't run).
|
||
state.districts.clear();
|
||
assert!(build_district_grid(&state).is_none());
|
||
}
|
||
|
||
/// T-1113: the region climate grid mirrors the district-grid encoding —
|
||
/// `None` when the Region layer hasn't run; dense row-major with the
|
||
/// integer wire quantization (deci-°C temp, `i16::MIN` airless sentinel)
|
||
/// when it has.
|
||
#[test]
|
||
fn build_region_grid_encodes_dense_quantized_climate() {
|
||
use crate::atlas::region_profile::{RegionClock, RegionProfile, SeasonPhase, WeatherState};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
// Empty regions → None (the Region layer hasn't run).
|
||
assert!(build_region_grid(&state).is_none());
|
||
|
||
// A 2×1 covering grid: one temperate region, one airless-style region
|
||
// (mean_temp_c = None → the sentinel).
|
||
state.regions.insert(
|
||
(0, 0),
|
||
RegionProfile {
|
||
pos: (0, 0),
|
||
clock: RegionClock {
|
||
season: SeasonPhase::Summer,
|
||
weather: WeatherState::Clear,
|
||
mean_temp_c: Some(12.34),
|
||
},
|
||
latitude_deg: 45.0,
|
||
moisture_q: 80,
|
||
},
|
||
);
|
||
state.regions.insert(
|
||
(1, 0),
|
||
RegionProfile {
|
||
pos: (1, 0),
|
||
clock: RegionClock {
|
||
season: SeasonPhase::Winter,
|
||
weather: WeatherState::Snow,
|
||
mean_temp_c: None,
|
||
},
|
||
latitude_deg: -10.0,
|
||
moisture_q: 5,
|
||
},
|
||
);
|
||
|
||
let grid = build_region_grid(&state).expect("regions present → Some grid");
|
||
assert_eq!((grid.cols, grid.rows), (2, 1));
|
||
assert_eq!(grid.season.len(), 2);
|
||
assert_eq!(grid.season[0], SeasonPhase::Summer as u8);
|
||
assert_eq!(grid.weather[0], WeatherState::Clear as u8);
|
||
// 12.34 °C → 123 deci-°C (rounded).
|
||
assert_eq!(grid.mean_temp_dc[0], 123);
|
||
assert_eq!(grid.moisture_q[0], 80);
|
||
assert_eq!(grid.season[1], SeasonPhase::Winter as u8);
|
||
assert_eq!(grid.weather[1], WeatherState::Snow as u8);
|
||
assert_eq!(
|
||
grid.mean_temp_dc[1], REGION_TEMP_NONE_DC,
|
||
"airless None maps to the sentinel"
|
||
);
|
||
assert_eq!(grid.moisture_q[1], 5);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DistrictWindowLayer (D-226 T-1124 amendment, T-1137)
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Minimal deterministic heightmap fixture, mirroring
|
||
/// `district_profile::tests::test_hm` (T-1137: the window path shares the
|
||
/// same on-demand `derive_district` call, so it earns the same fixture
|
||
/// shape).
|
||
fn window_test_hm() -> crate::atlas::heightmap::BodyHeightmap {
|
||
use crate::atlas::heightmap::BodyHeightmap;
|
||
let (w, h) = (64u32, 32u32);
|
||
let n = (w * h) as usize;
|
||
let data = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32 / h as f32;
|
||
let c = (i % w as usize) as f32 / w as f32;
|
||
(r * 0.6 + c * 0.4).min(1.0)
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "test".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.3,
|
||
}
|
||
}
|
||
|
||
fn window_test_ta(
|
||
hm: &crate::atlas::heightmap::BodyHeightmap,
|
||
) -> crate::atlas::features::TerrainAnalysis {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::features::TerrainAnalysis;
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
TerrainAnalysis::analyze(hm, &dr)
|
||
}
|
||
|
||
/// T-1170: the `RiverNetwork` companion to [`window_test_ta`] — most
|
||
/// existing window-builder tests don't care about courses at all (this
|
||
/// synthetic gradient fixture may have zero river cells), so an empty
|
||
/// default is the common case; call sites that DO care about courses use
|
||
/// a real fixture (`window_test_gj1c_network`) instead.
|
||
fn window_test_river_network(
|
||
hm: &crate::atlas::heightmap::BodyHeightmap,
|
||
) -> crate::atlas::body_world_state::RiverNetwork {
|
||
use crate::atlas::drainage;
|
||
drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network
|
||
}
|
||
|
||
fn window_test_params() -> crate::atlas::district_profile::BodyParams {
|
||
crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// `build_district_window_layer` produces a dense `n × n` row-major grid
|
||
/// (the `DistrictGridLayer`/`RegionGridLayer` indexing convention) whose
|
||
/// cell count and per-array lengths match `n`, and whose values are
|
||
/// pulled straight from the corresponding `derive_district` profile field
|
||
/// (T-1137).
|
||
#[test]
|
||
fn build_district_window_layer_produces_dense_n_by_n_grid() {
|
||
let hm = window_test_hm();
|
||
let ta = window_test_ta(&hm);
|
||
let rn = window_test_river_network(&hm);
|
||
let params = window_test_params();
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(42).derive(SeedDomain::Body, 1);
|
||
|
||
let n = 4u32;
|
||
let layer = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
(10, -5),
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
|
||
assert_eq!(layer.center, (10, -5));
|
||
assert_eq!(layer.n, n);
|
||
assert_eq!(layer.granularity_v2, WindowGranularity::District);
|
||
assert_eq!(layer.min_wl_m, 0);
|
||
let cells = (n * n) as usize;
|
||
assert_eq!(layer.morphology.len(), cells);
|
||
assert_eq!(layer.elev_q.len(), cells);
|
||
assert_eq!(layer.temp_dc.len(), cells);
|
||
assert_eq!(layer.moisture_q.len(), cells);
|
||
assert_eq!(layer.vegetation.len(), cells);
|
||
assert_eq!(layer.glaciation.len(), cells);
|
||
|
||
// Spot-check one cell against a direct derive_district call — the
|
||
// window builder must not transform the profile's values, only pack
|
||
// them (row 0, col 0 → district (center.0 - n/2, center.1 - n/2)).
|
||
let half = (n / 2) as i32;
|
||
let dp = (10 - half, -5 - half);
|
||
let prof = crate::atlas::district_profile::derive_district(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
dp,
|
||
&climate,
|
||
);
|
||
assert_eq!(layer.morphology[0], prof.morphology_zone as u8);
|
||
assert_eq!(layer.elev_q[0], prof.elev_q.clamp(0, 100) as u8);
|
||
assert_eq!(layer.moisture_q[0], prof.moisture_q.clamp(0, 100) as u8);
|
||
assert_eq!(layer.vegetation[0], prof.vegetation_class as u8);
|
||
assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8);
|
||
}
|
||
|
||
/// T-1170/T-1168 A5 integration, strengthened by D-256: the batch path
|
||
/// (`derive_district_profile`, sourcing courses via `near_perennial_water_at`
|
||
/// on demand) and the window path (`build_district_window_layer`,
|
||
/// sourcing courses via the pre-invented `Vec<InventedCourse>`) must
|
||
/// resolve the SAME riparian verdict for the SAME world position —
|
||
/// Ruling 4b's "batch and window paths can never silently disagree"
|
||
/// binding requirement, checked end to end (not just at the
|
||
/// `near_perennial_water`/`near_perennial_water_at` unit level).
|
||
///
|
||
/// **Design note (D-256):** pre-D-256 this test deliberately did NOT
|
||
/// compare the batch and window paths' full `DistrictProfile` output for
|
||
/// "the same district" — `derive_district_profile`'s cell-aggregate-centre
|
||
/// sampling and the window path's district-origin sampling resolved
|
||
/// genuinely different world positions for a shared nominal `DistrictPos`
|
||
/// (the batch namespace collision D-256 rules on). With sampling unified
|
||
/// (one derive core, D-256(c)), that framing is now FALSE: batch and
|
||
/// window positions agree BY CONSTRUCTION — `derive_district_profile` is
|
||
/// a thin wrapper over the exact same [`crate::atlas::district_profile::derive_at_metres`]
|
||
/// family the window path calls, at the survey cell's own D-256(b) centre
|
||
/// world metres. `full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre`
|
||
/// below is the strengthened full-profile bit-identical check this design
|
||
/// note used to explicitly rule out; this test keeps the riparian-signal
|
||
/// check as a focused shared-exact-position regression (the ONE hand-wired
|
||
/// signal T-1168 added, worth its own targeted assertion).
|
||
#[test]
|
||
fn window_and_batch_paths_agree_on_riparian_signal_near_a_real_river_edge() {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::river_course;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
assert!(
|
||
!rn.river_cells.is_empty(),
|
||
"GJ1c downsample must have river cells for this test to be meaningful"
|
||
);
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = DISTRICT_M as f64;
|
||
|
||
// Invent a real edge and sample a point exactly on its course.
|
||
let edges = river_course::build_edges(rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == river_course::EdgeTerminusKind::Interior)
|
||
.expect("GJ1c should have an interior river edge");
|
||
let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
let on_course = course.points[course.points.len() / 2];
|
||
|
||
// Batch path: near_perennial_water_at (invents nearby edges on demand
|
||
// from `rn` directly).
|
||
let batch_signal = river_course::near_perennial_water_at(
|
||
seed,
|
||
&ta,
|
||
¶ms,
|
||
rn,
|
||
on_course,
|
||
station_spacing_m,
|
||
0.0,
|
||
);
|
||
|
||
// Window path: invent_courses_near_window (the SAME pre-invention step
|
||
// `build_district_window_layer` uses) around a window rect containing
|
||
// `on_course`, then near_perennial_water against that pre-invented list.
|
||
let window_rect = (
|
||
on_course.0 - 10_000.0,
|
||
on_course.1 - 10_000.0,
|
||
on_course.0 + 10_000.0,
|
||
on_course.1 + 10_000.0,
|
||
);
|
||
let invented = invent_courses_near_window(
|
||
seed,
|
||
¶ms,
|
||
&ta,
|
||
rn,
|
||
window_rect,
|
||
WindowGranularity::District,
|
||
0.0,
|
||
);
|
||
let window_signal = river_course::near_perennial_water(on_course, &invented);
|
||
|
||
assert!(
|
||
batch_signal,
|
||
"a point exactly on an invented course must read near_perennial_water_at == true (batch path)"
|
||
);
|
||
assert_eq!(
|
||
batch_signal, window_signal,
|
||
"batch (near_perennial_water_at) and window (invent_courses_near_window + \
|
||
near_perennial_water) paths must agree on the riparian verdict at the SAME \
|
||
world position {on_course:?}"
|
||
);
|
||
}
|
||
|
||
/// D-256(c): the batch survey-cell profile == `derive_at_metres` at the
|
||
/// SAME survey-cell-centre world metres — bit-identical, field for field
|
||
/// (basin overridden on the expectation since the core always returns the
|
||
/// default and the wrapper post-call-overrides it; riparian threaded
|
||
/// equivalently through both paths' own mechanism). This is the
|
||
/// strengthened replacement for the pre-D-256 "legitimate different
|
||
/// positions" design note on the riparian-only test above — positions now
|
||
/// agree by construction, so the FULL profile must too.
|
||
#[test]
|
||
fn full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre() {
|
||
use crate::atlas::district_profile::{self, ClimateConstants};
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::scale::{self, BasinDirection, SurveyCellPos};
|
||
use std::collections::BTreeMap;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
let climate = ClimateConstants::default();
|
||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT;
|
||
|
||
// An interior survey cell (not clamped at the grid edge — that case
|
||
// is covered by the dedicated edge-truncation test below).
|
||
let cell = SurveyCellPos(10, 6);
|
||
let basin = BasinDirection::East;
|
||
|
||
let batch_profile = district_profile::derive_district_profile(
|
||
seed,
|
||
¶ms,
|
||
&ta,
|
||
cell,
|
||
gcpr,
|
||
&climate,
|
||
"GJ1c",
|
||
&BTreeMap::new(),
|
||
basin,
|
||
Some(rn),
|
||
);
|
||
|
||
// The SAME survey-cell-centre world metres, resolved via the SAME
|
||
// D-256(b) bridge function derive_district_profile uses internally.
|
||
let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m(
|
||
cell,
|
||
gcpr,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
|
||
// Riparian equivalent: near_perennial_water_at against the same
|
||
// RiverNetwork, same station spacing/cutoff derive_district_profile
|
||
// uses internally — the SAME on-demand course invention, not the
|
||
// window path's pre-invented slice (this is the batch-vs-on-demand
|
||
// core identity, not the batch-vs-window riparian check above).
|
||
//
|
||
// derive_at_metres's PUBLIC signature only accepts a pre-invented
|
||
// `nearby_courses` slice (never a raw bool — Ruling 4a), so to force
|
||
// the SAME riparian verdict through the public path a single-point
|
||
// synthetic course exactly at (world_x_m, world_y_m) is threaded when
|
||
// the on-demand signal is true; an empty slice when false. Either way
|
||
// `near_perennial_water((wx,wy), courses)` evaluates to the identical
|
||
// bool the batch core read.
|
||
let near_perennial_water = river_course::near_perennial_water_at(
|
||
seed,
|
||
&ta,
|
||
¶ms,
|
||
rn,
|
||
(world_x_m, world_y_m),
|
||
DISTRICT_M as f64,
|
||
0.0,
|
||
);
|
||
let riparian_equivalent: Vec<InventedCourse> = if near_perennial_water {
|
||
vec![InventedCourse {
|
||
edge_id: 0,
|
||
class: 0,
|
||
terminus: EdgeTerminusKind::Interior,
|
||
points: vec![(world_x_m, world_y_m)],
|
||
bbox: (world_x_m, world_y_m, world_x_m, world_y_m),
|
||
}]
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let mut expected = district_profile::derive_at_metres(
|
||
seed,
|
||
"GJ1c",
|
||
¶ms,
|
||
&ta,
|
||
world_x_m,
|
||
world_y_m,
|
||
&climate,
|
||
0.0,
|
||
&riparian_equivalent,
|
||
);
|
||
// basin_direction (inert field, D-256(c) proof) — the core always
|
||
// returns the default; the wrapper post-call-overrides it.
|
||
expected.basin_direction = basin;
|
||
|
||
assert_eq!(
|
||
batch_profile.morphology_zone as u8,
|
||
expected.morphology_zone as u8
|
||
);
|
||
assert_eq!(
|
||
batch_profile.tectonic_class as u8,
|
||
expected.tectonic_class as u8
|
||
);
|
||
assert_eq!(
|
||
batch_profile.glaciation_grade as u8,
|
||
expected.glaciation_grade as u8
|
||
);
|
||
assert_eq!(
|
||
batch_profile.precipitation_class as u8,
|
||
expected.precipitation_class as u8
|
||
);
|
||
assert_eq!(batch_profile.slope_q, expected.slope_q);
|
||
assert_eq!(batch_profile.elev_q, expected.elev_q);
|
||
assert_eq!(batch_profile.ocean_fraction_q, expected.ocean_fraction_q);
|
||
assert_eq!(batch_profile.river_threshold, expected.river_threshold);
|
||
assert_eq!(batch_profile.temperature_c, expected.temperature_c);
|
||
assert_eq!(batch_profile.moisture_q, expected.moisture_q);
|
||
assert_eq!(
|
||
batch_profile.vegetation_class as u8,
|
||
expected.vegetation_class as u8
|
||
);
|
||
assert_eq!(
|
||
batch_profile.basin_direction as u8,
|
||
expected.basin_direction as u8
|
||
);
|
||
}
|
||
|
||
/// D-256(c) invariant: the wrapper (`derive_district_profile`) ≡ core
|
||
/// (`derive_at_metres`) identity holds at survey-grid edge cases — the
|
||
/// anti-meridian column (`px` near the wrap boundary), the pole rows
|
||
/// (latitude clamp), and edge-truncated cells (the covering pixel block
|
||
/// itself clamped at `w`/`h`, not just the resulting position). No
|
||
/// riparian signal threaded here (both sides get `river_network: None` /
|
||
/// `nearby_courses: &[]` — the riparian equivalence is the DEDICATED
|
||
/// concern of the test above; this one isolates the geometric position
|
||
/// mapping across the grid's hard edges).
|
||
#[test]
|
||
fn wrapper_matches_core_at_survey_grid_edge_cases() {
|
||
use crate::atlas::district_profile::{self, ClimateConstants};
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::scale::{self, BasinDirection, SurveyCellPos};
|
||
use std::collections::BTreeMap;
|
||
|
||
// The committed GJ1c heightmap (same fixture the other integration
|
||
// tests in this module use), downsampled to the standard 256×128
|
||
// working grid so the survey raster is a real body's shape.
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let hm = heightmap.downsample(256, 128);
|
||
let dr = crate::atlas::drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&hm, &dr);
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 2);
|
||
let climate = ClimateConstants::default();
|
||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT;
|
||
let survey_cols = (hm.width as usize).div_ceil(gcpr) as i32; // 32
|
||
let survey_rows = (hm.height as usize).div_ceil(gcpr) as i32; // 16
|
||
|
||
let cases: &[(&str, SurveyCellPos)] = &[
|
||
("west edge / row 0 corner", SurveyCellPos(0, 0)),
|
||
(
|
||
"anti-meridian column (max col)",
|
||
SurveyCellPos(survey_cols - 1, survey_rows / 2),
|
||
),
|
||
("north pole row", SurveyCellPos(survey_cols / 2, 0)),
|
||
(
|
||
"south pole row",
|
||
SurveyCellPos(survey_cols / 2, survey_rows - 1),
|
||
),
|
||
(
|
||
"edge-truncated SE corner",
|
||
SurveyCellPos(survey_cols - 1, survey_rows - 1),
|
||
),
|
||
];
|
||
|
||
for &(label, cell) in cases {
|
||
let batch_profile = district_profile::derive_district_profile(
|
||
seed,
|
||
¶ms,
|
||
&ta,
|
||
cell,
|
||
gcpr,
|
||
&climate,
|
||
"GJ1c",
|
||
&BTreeMap::new(),
|
||
BasinDirection::North, // default — no override under test here
|
||
None,
|
||
);
|
||
let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m(
|
||
cell,
|
||
gcpr,
|
||
ta.w,
|
||
ta.h,
|
||
params.body_radius_km,
|
||
);
|
||
let expected = district_profile::derive_at_metres(
|
||
seed,
|
||
"GJ1c",
|
||
¶ms,
|
||
&ta,
|
||
world_x_m,
|
||
world_y_m,
|
||
&climate,
|
||
0.0,
|
||
&[],
|
||
);
|
||
|
||
assert_eq!(
|
||
batch_profile.morphology_zone as u8, expected.morphology_zone as u8,
|
||
"[{label}] morphology_zone mismatch at {cell:?}"
|
||
);
|
||
assert_eq!(
|
||
batch_profile.elev_q, expected.elev_q,
|
||
"[{label}] elev_q mismatch at {cell:?}"
|
||
);
|
||
assert_eq!(
|
||
batch_profile.slope_q, expected.slope_q,
|
||
"[{label}] slope_q mismatch at {cell:?}"
|
||
);
|
||
assert_eq!(
|
||
batch_profile.ocean_fraction_q, expected.ocean_fraction_q,
|
||
"[{label}] ocean_fraction_q mismatch at {cell:?}"
|
||
);
|
||
assert_eq!(
|
||
batch_profile.temperature_c, expected.temperature_c,
|
||
"[{label}] temperature_c mismatch at {cell:?}"
|
||
);
|
||
assert_eq!(
|
||
batch_profile.moisture_q, expected.moisture_q,
|
||
"[{label}] moisture_q mismatch at {cell:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Discipline item 3(a), mandatory: two overlapping windows sharing a
|
||
/// stretch of the same edge must produce BYTE-IDENTICAL course points
|
||
/// for that shared stretch (Ruling 1e, the window-independence
|
||
/// invariant — "stations are generated at deterministic global
|
||
/// arc-length positions along the edge; the window crops, it never
|
||
/// re-parametrizes"). Two windows at different centers, both containing
|
||
/// the same real GJ1c edge, must report the identical `RiverCourse` for
|
||
/// that edge wherever both windows' cropped ranges overlap.
|
||
#[test]
|
||
fn overlapping_windows_produce_byte_identical_course_points() {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::river_course;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = DISTRICT_M as f64;
|
||
|
||
let edges = river_course::build_edges(rn);
|
||
// Find the LONGEST interior edge (by point count) so the two windows
|
||
// below can each cover a genuine, well-inside-their-bounds stretch —
|
||
// a short edge's course could produce degenerate/edge-of-range
|
||
// overlaps that don't actually exercise the invariant.
|
||
let edge = edges
|
||
.iter()
|
||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior)
|
||
.max_by_key(|e| {
|
||
let course =
|
||
river_course::invent_course(seed, e, &ta, ¶ms, station_spacing_m, 0.0);
|
||
course.points.len()
|
||
})
|
||
.expect("GJ1c should have an interior river edge");
|
||
let full_course =
|
||
river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
assert!(
|
||
full_course.points.len() >= 4,
|
||
"need a course with enough stations to construct two overlapping windows"
|
||
);
|
||
|
||
// A midpoint on the course — the shared stretch two different
|
||
// windows will both cover.
|
||
let mid = full_course.points[full_course.points.len() / 2];
|
||
|
||
// Two DIFFERENT window rects, both containing `mid` well inside
|
||
// their bounds (so both windows' crop ranges include the shared
|
||
// stretch, not just a single boundary point).
|
||
let window_a = (
|
||
mid.0 - 20_000.0,
|
||
mid.1 - 20_000.0,
|
||
mid.0 + 5_000.0,
|
||
mid.1 + 5_000.0,
|
||
);
|
||
let window_b = (
|
||
mid.0 - 5_000.0,
|
||
mid.1 - 5_000.0,
|
||
mid.0 + 20_000.0,
|
||
mid.1 + 20_000.0,
|
||
);
|
||
|
||
let invented_a = invent_courses_near_window(
|
||
seed,
|
||
¶ms,
|
||
&ta,
|
||
rn,
|
||
window_a,
|
||
WindowGranularity::District,
|
||
0.0,
|
||
);
|
||
let invented_b = invent_courses_near_window(
|
||
seed,
|
||
¶ms,
|
||
&ta,
|
||
rn,
|
||
window_b,
|
||
WindowGranularity::District,
|
||
0.0,
|
||
);
|
||
|
||
let course_a = invented_a
|
||
.iter()
|
||
.find(|c| c.edge_id == edge.edge_id)
|
||
.expect("edge must be invented for window A");
|
||
let course_b = invented_b
|
||
.iter()
|
||
.find(|c| c.edge_id == edge.edge_id)
|
||
.expect("edge must be invented for window B");
|
||
|
||
// Ruling 1e's actual invariant: invent_courses_near_window returns
|
||
// the FULL invented course for any edge that culls in — never
|
||
// window-cropped or re-parametrized at this layer (cropping happens
|
||
// later, in crop_courses_for_wire). So the two windows' invented
|
||
// points for the SAME edge must be byte-identical in full, not just
|
||
// over some overlap region — this is the direct proof that
|
||
// invention is independent of the window rect entirely.
|
||
assert_eq!(
|
||
course_a.points, course_b.points,
|
||
"the same edge invented from two different windows must be byte-identical (D-227/Ruling 1e)"
|
||
);
|
||
}
|
||
|
||
/// Discipline item 3(b), mandatory: Quarter course points must stay
|
||
/// within the truncated-octave amplitude bound of the District course at
|
||
/// the same world position (Ruling 3b's cross-rung invariant — "the
|
||
/// Quarter course is the District course plus octaves in the (1,024
|
||
/// m..4,096 m) band"). Checked via the perpendicular deviation between
|
||
/// the two rungs' station lists never exceeding the District-rung peak
|
||
/// amplitude cap by more than a small tolerance (Quarter's extra octaves
|
||
/// can only ADD bounded displacement on top of the District shape, never
|
||
/// diverge unboundedly).
|
||
#[test]
|
||
fn quarter_course_stays_within_district_amplitude_bound() {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::river_course;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
|
||
let edges = river_course::build_edges(rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == river_course::EdgeTerminusKind::Interior)
|
||
.expect("GJ1c should have an interior river edge");
|
||
|
||
let district_course = river_course::invent_course(
|
||
seed,
|
||
edge,
|
||
&ta,
|
||
¶ms,
|
||
DISTRICT_M as f64,
|
||
2.0 * DISTRICT_M as f64, // District's real Nyquist-floor cutoff
|
||
);
|
||
let quarter_course = river_course::invent_course(
|
||
seed,
|
||
edge,
|
||
&ta,
|
||
¶ms,
|
||
crate::atlas::scale::QUARTER_M as f64,
|
||
2.0 * crate::atlas::scale::QUARTER_M as f64, // Quarter's real cutoff
|
||
);
|
||
|
||
// For each District station, find the nearest Quarter station (by
|
||
// arc-length proxy: nearest point in world space) and confirm the
|
||
// deviation stays within the District-rung amplitude cap (Stage B's
|
||
// own hard cap, Ruling 3c) plus a small numeric tolerance — Quarter
|
||
// must refine the shape, never blow past the amplitude budget the
|
||
// SAME peak-fraction-of-chord cap governs at every rung.
|
||
let anchor_a = district_course.points[0];
|
||
let anchor_b = *district_course.points.last().unwrap();
|
||
let chord_m =
|
||
((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt();
|
||
let cap_m = (chord_m * river_course::STAGE_B_PEAK_FRACTION_OF_CHORD)
|
||
.min(crate::atlas::scale::QUARTER_M as f64 * 0.5)
|
||
* 1.35; // widest class_scale entry (trunk)
|
||
|
||
for &dp in &district_course.points {
|
||
let nearest_q = quarter_course
|
||
.points
|
||
.iter()
|
||
.min_by(|a, b| {
|
||
let da = (a.0 - dp.0).powi(2) + (a.1 - dp.1).powi(2);
|
||
let db = (b.0 - dp.0).powi(2) + (b.1 - dp.1).powi(2);
|
||
da.partial_cmp(&db).unwrap()
|
||
})
|
||
.unwrap();
|
||
let dist = ((nearest_q.0 - dp.0).powi(2) + (nearest_q.1 - dp.1).powi(2)).sqrt();
|
||
assert!(
|
||
dist <= cap_m + 50.0, // small slack for nearest-station (not exact arc-length) matching
|
||
"Quarter course deviates {dist} m from the nearest District station — \
|
||
exceeds the {cap_m} m amplitude bound (Ruling 3b cross-rung invariant)"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **T-1170 PR #197 review, Hoshe #1 acceptance test (blocking, permanent
|
||
/// — not a throwaway probe).** Every real Mouth edge on the GJ1c golden
|
||
/// fixture (256×128 downsample, the SAME fixture `cascade_golden.rs`
|
||
/// pins — 3 mouths: `[(38,47), (38,98), (124,239)]`) must resolve
|
||
/// `CourseTerminus::Mouth`, not `CourseTerminus::None`.
|
||
///
|
||
/// **What this guards:** before the fix, `build_edges` set
|
||
/// `downstream = upstream` for every Mouth edge (a same-cell
|
||
/// placeholder — the SAME discard-then-need-it-later anti-pattern
|
||
/// Ruling 2b's `river_downstream` field fixed for interior pointers,
|
||
/// applied a second time to the seaward neighbor `extract_river_network`
|
||
/// already computes and then threw away). That zeroed the chord
|
||
/// (`chord_m < 1.0`), which tripped `invent_course`'s degenerate
|
||
/// single-point return, which made `resolve_mouth_terminus`'s station
|
||
/// walk a no-op (a 1-point course can't reach the `pts.len() >= 2`
|
||
/// fallback probe either) — all 3 real GJ1c mouths silently resolved
|
||
/// `CourseTerminus::None` instead of `Mouth`, and since Ruling 3g retired
|
||
/// the District/Quarter draw-time clip on the promise of real termini,
|
||
/// mouths would have disappeared entirely at those rungs. The fix:
|
||
/// `RiverNetwork::river_seaward` (additive, captured in the same
|
||
/// `extract_river_network` pass) carries the real seaward neighbor
|
||
/// through to `build_edges`, giving Mouth edges a genuine ~one-cell
|
||
/// chord to invent a course along.
|
||
#[test]
|
||
fn all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none() {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::river_course;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = DISTRICT_M as f64;
|
||
|
||
let edges = river_course::build_edges(rn);
|
||
let mouth_edges: Vec<_> = edges
|
||
.iter()
|
||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||
.collect();
|
||
assert_eq!(
|
||
mouth_edges.len(),
|
||
rn.mouths.len(),
|
||
"build_edges must produce exactly one Mouth edge per RiverNetwork.mouths entry"
|
||
);
|
||
assert_eq!(
|
||
mouth_edges.len(),
|
||
3,
|
||
"GJ1c at this downsample is expected to have 3 real mouths (matches the \
|
||
committed cascade_golden fixture) — if this count changes, re-verify against \
|
||
tests/golden/cascade_layer1.json before updating this assertion"
|
||
);
|
||
|
||
let mut resolved_mouth_count = 0;
|
||
for edge in &mouth_edges {
|
||
// Sanity: the fix means Mouth edges get a real, non-degenerate
|
||
// chord toward the seaward neighbor — never upstream==downstream.
|
||
assert_ne!(
|
||
edge.upstream, edge.downstream,
|
||
"Mouth edge {:?} still has a same-cell placeholder downstream — \
|
||
river_seaward threading regressed",
|
||
edge.edge_id
|
||
);
|
||
|
||
let course =
|
||
river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
assert!(
|
||
course.points.len() >= 2,
|
||
"Mouth edge {:?} invented a degenerate {}-point course — the chord-length \
|
||
fix regressed",
|
||
edge.edge_id,
|
||
course.points.len()
|
||
);
|
||
|
||
// Window rect generous enough to contain the whole short mouth
|
||
// course (mouths are ~one cell chord, so a wide margin is cheap).
|
||
let (min_x, max_x) = course
|
||
.points
|
||
.iter()
|
||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
|
||
(lo.min(p.0), hi.max(p.0))
|
||
});
|
||
let (min_y, max_y) = course
|
||
.points
|
||
.iter()
|
||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
|
||
(lo.min(p.1), hi.max(p.1))
|
||
});
|
||
let margin = 50_000.0;
|
||
let window_rect = (
|
||
min_x - margin,
|
||
min_y - margin,
|
||
max_x + margin,
|
||
max_y + margin,
|
||
);
|
||
|
||
let wire = crop_course_to_window(
|
||
&course,
|
||
window_rect,
|
||
seed,
|
||
"GJ1c",
|
||
¶ms,
|
||
&ta,
|
||
&climate,
|
||
0.0,
|
||
station_spacing_m,
|
||
)
|
||
.unwrap_or_else(|| {
|
||
panic!(
|
||
"Mouth edge {:?} cropped to nothing in its own window",
|
||
edge.edge_id
|
||
)
|
||
});
|
||
|
||
assert_eq!(
|
||
wire.terminus,
|
||
CourseTerminus::Mouth,
|
||
"Mouth edge {:?} (upstream {:?}, downstream {:?}) resolved {:?} instead of \
|
||
CourseTerminus::Mouth",
|
||
edge.edge_id,
|
||
edge.upstream,
|
||
edge.downstream,
|
||
wire.terminus
|
||
);
|
||
resolved_mouth_count += 1;
|
||
}
|
||
|
||
assert_eq!(
|
||
resolved_mouth_count, 3,
|
||
"acceptance criterion (Hoshe #1): all 3 real GJ1c mouths must resolve \
|
||
CourseTerminus::Mouth"
|
||
);
|
||
}
|
||
|
||
/// **T-1170 PR #197 review round 2 (coordinator's live GJ380c/Lendel
|
||
/// repro) — the upstream-cut coverage gap in the test above, closed.**
|
||
///
|
||
/// `all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` derives its
|
||
/// window rect from the invented course's OWN min/max point bbox, so by
|
||
/// construction that window always contains the WHOLE course (both
|
||
/// `first_in == 0` and `last_in == n-1`) — it never exercises a window
|
||
/// that cuts the UPSTREAM anchor while the true downstream terminus
|
||
/// still falls inside. `crop_course_to_window`'s terminus branch is
|
||
/// driven entirely by `last_in`/`hi` (the downstream side); this test is
|
||
/// the direct proof that an upstream cut (`first_in > 0`, i.e. `lo > 0`)
|
||
/// does NOT collapse the terminus flag to `ContinuesBeyondWindow` — the
|
||
/// coordinator's hypothesis (a) from the live-server investigation,
|
||
/// falsified here as a permanent regression case rather than only a
|
||
/// throwaway probe (`mouth_repro_probe.rs`, deleted after this landed).
|
||
///
|
||
/// Window construction: take one real GJ1c mouth edge's full invented
|
||
/// course, find the TRUE (uncropped) terminus point, then build a window
|
||
/// rect deliberately offset upstream along the course's own tail
|
||
/// direction so its near edge sits well past the upstream anchor (cutting
|
||
/// it out of range) while its far edge still comfortably contains the
|
||
/// true terminus.
|
||
#[test]
|
||
fn mouth_terminus_survives_an_upstream_only_crop() {
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
use crate::atlas::river_course;
|
||
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
let params = crate::atlas::district_profile::BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
};
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = DISTRICT_M as f64;
|
||
|
||
let edges = river_course::build_edges(rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||
.expect("GJ1c fixture must have at least one Mouth edge");
|
||
|
||
let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
assert!(
|
||
course.points.len() >= 2,
|
||
"need a non-degenerate course to construct a meaningful upstream cut"
|
||
);
|
||
let upstream_anchor = course.points[0];
|
||
let true_end = *course.points.last().unwrap();
|
||
|
||
// Window offset upstream along the course's own tail direction (the
|
||
// last segment), far enough that the upstream anchor falls outside
|
||
// the window but the true terminus stays comfortably inside.
|
||
let second_last = course.points[course.points.len().saturating_sub(2)];
|
||
let (dx, dy) = (true_end.0 - second_last.0, true_end.1 - second_last.1);
|
||
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||
let (ux, uy) = (dx / len, dy / len);
|
||
// Half the upstream->downstream distance keeps the window's near
|
||
// edge well clear of the upstream anchor for any real mouth chord
|
||
// (mouth edges are short, ~one D8 step), while the far edge margin
|
||
// below still comfortably covers the terminus.
|
||
let chord_m = ((true_end.0 - upstream_anchor.0).powi(2)
|
||
+ (true_end.1 - upstream_anchor.1).powi(2))
|
||
.sqrt();
|
||
let offset_m = (chord_m * 0.5).max(5_000.0);
|
||
let center = (true_end.0 - ux * offset_m, true_end.1 - uy * offset_m);
|
||
let half_extent_m = (chord_m * 0.5).max(5_000.0);
|
||
let window_rect = (
|
||
center.0 - half_extent_m,
|
||
center.1 - half_extent_m,
|
||
center.0 + half_extent_m,
|
||
center.1 + half_extent_m,
|
||
);
|
||
|
||
// Sanity on the window construction itself (not the code under
|
||
// test): the upstream anchor must genuinely be cropped out, and the
|
||
// true terminus must genuinely be inside — otherwise this test
|
||
// isn't exercising the branch it claims to.
|
||
let inside = |p: (f64, f64)| {
|
||
p.0 >= window_rect.0
|
||
&& p.0 <= window_rect.2
|
||
&& p.1 >= window_rect.1
|
||
&& p.1 <= window_rect.3
|
||
};
|
||
assert!(
|
||
!inside(upstream_anchor),
|
||
"test construction error: upstream anchor {upstream_anchor:?} must be OUTSIDE \
|
||
the window {window_rect:?} for this to be a real upstream-cut case"
|
||
);
|
||
assert!(
|
||
inside(true_end),
|
||
"test construction error: true terminus {true_end:?} must be INSIDE the \
|
||
window {window_rect:?} for this to test the terminus-survives claim"
|
||
);
|
||
|
||
let wire = crop_course_to_window(
|
||
&course,
|
||
window_rect,
|
||
seed,
|
||
"GJ1c",
|
||
¶ms,
|
||
&ta,
|
||
&climate,
|
||
0.0,
|
||
station_spacing_m,
|
||
)
|
||
.unwrap_or_else(|| panic!("course cropped to nothing despite containing the terminus"));
|
||
|
||
assert_eq!(
|
||
wire.terminus,
|
||
CourseTerminus::Mouth,
|
||
"an upstream-only crop (anchor cut, true terminus still in-window) must NOT \
|
||
collapse the terminus flag — got {:?} for edge {:?}",
|
||
wire.terminus,
|
||
edge.edge_id
|
||
);
|
||
}
|
||
|
||
/// **T-1170 live GJ380c/Lendel reconciliation dossier (coordinator's
|
||
/// request) — walk/paint agreement invariant, pinned permanently.**
|
||
///
|
||
/// Live capture at GJ380c Quarter n=32 (server-clamped to n=16 — see
|
||
/// below), district (13195,-2383), reported the ENTIRE visible course
|
||
/// (44 wire points, `terminus=Mouth`) painting as water tones, with the
|
||
/// resolved terminus apparently landing deep in open water. Investigated
|
||
/// via an instrumented reproduction of `resolve_mouth_terminus`'s exact
|
||
/// walk (temporary probe, deleted after this landed) — DETERMINATION:
|
||
/// this is not a bug. The window's world-metre rect
|
||
/// (`x∈[27006976,27039744]`) simply sits ~60+ km from the course's
|
||
/// upstream land anchor and close to the resolved coastal terminus, so
|
||
/// the overwhelming majority of what's IN FRAME is genuinely
|
||
/// Lake/OpenOcean-painted — confirmed by hand-computing the exact RGB
|
||
/// (`MORPHOLOGY_RGB_OPAQUE` × elevation lightness) for the coordinator's
|
||
/// sampled pixel colors, which matched Lake/OpenOcean to the rounding
|
||
/// digit. The walk's own station-by-station classification (178 land
|
||
/// stations, then 35 water stations, ZERO flip-flops) is perfectly
|
||
/// monotonic and the resolved terminus sits ~240 m from the true
|
||
/// land→water crossing — not "tens of km past coast".
|
||
///
|
||
/// **The invariant this test pins, since the raw "N consecutive water
|
||
/// stations" framing turned out not to be the real signal:** the wire
|
||
/// course's resolved `Mouth` terminus point must land in a window CELL
|
||
/// whose PAINTED morphology is genuinely `OpenOcean`/`Lake` (never a
|
||
/// land-family zone) — i.e. the termination walk and the window's own
|
||
/// per-cell classification, sampled independently via the SAME
|
||
/// `derive_at_metres` call, must agree at the terminus. This is the
|
||
/// walk/paint reconciliation the dossier was asked to determine, made
|
||
/// permanent and mechanism-agnostic (it would catch a REAL divergence —
|
||
/// wrap slip, sign flip, station-order bug — regardless of which
|
||
/// specific window happens to expose it).
|
||
#[test]
|
||
fn mouth_terminus_lands_in_a_painted_water_cell_on_real_gj380c() {
|
||
use crate::atlas::body_params_reader::BodyParamsReader;
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||
use crate::atlas::river_course;
|
||
|
||
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||
let src = manifest.join("../wiki/star-systems/GJ-380/bodies/GJ380c/heightmap.png");
|
||
let systems_db = manifest.join("data/systems.db");
|
||
|
||
let params_reader = BodyParamsReader::open(&systems_db).expect("open committed systems.db");
|
||
let params = params_reader
|
||
.read_body_params("GJ380c")
|
||
.expect("read GJ380c body params from committed systems.db");
|
||
|
||
let heightmap = load_heightmap_png(&src, "GJ380c", 0.3).expect("decode GJ380c heightmap");
|
||
let working = if heightmap.width > GRID_W || heightmap.height > GRID_H {
|
||
heightmap.downsample(GRID_W, GRID_H)
|
||
} else {
|
||
heightmap
|
||
};
|
||
|
||
let dr = drainage::analyze(
|
||
&working.data,
|
||
working.width,
|
||
working.height,
|
||
working.sea_level,
|
||
);
|
||
let ta = crate::atlas::features::TerrainAnalysis::analyze(&working, &dr);
|
||
let rn = &dr.river_network;
|
||
|
||
// The exact real mouth cell from the live capture.
|
||
let target_px: (u16, u16) = (63, 354);
|
||
let edges = river_course::build_edges(rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.edge_id == river_course::pack_cell_id(target_px))
|
||
.expect("GJ380c must have a Mouth edge at the live-captured cell");
|
||
assert_eq!(
|
||
edge.edge_id, 4129122,
|
||
"must be the same edge the live capture reported"
|
||
);
|
||
|
||
let seed = SeedChain::for_body(0, "GJ380c"); // production default world_seed
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
|
||
// The EXACT live window shape: district (13195,-2383), REQUESTED
|
||
// n=32, Quarter granularity — server-side clamps n=32 down to n=16
|
||
// at Quarter granularity (`clamp_window_n`: cap_n = sqrt(4096)/4 =
|
||
// 16), which `build_district_window_layer` does NOT do itself (it
|
||
// trusts n verbatim by its own doc) — the request-handling layer
|
||
// (`handle_atlas_request`) applies `clamp_window_n_v2` BEFORE
|
||
// calling it. Pass the already-clamped n=16 here to match what a
|
||
// real client request actually receives.
|
||
let center = (13195, -2383);
|
||
let clamped_n = 16u32;
|
||
|
||
let layer = build_district_window_layer(
|
||
seed,
|
||
"GJ380c",
|
||
¶ms,
|
||
&ta,
|
||
rn,
|
||
center,
|
||
clamped_n,
|
||
&climate,
|
||
WindowGranularity::Quarter,
|
||
0,
|
||
);
|
||
let wire = layer
|
||
.courses
|
||
.iter()
|
||
.find(|c| c.edge_id == edge.edge_id)
|
||
.expect("edge 4129122 must ship in this window, matching the live capture");
|
||
assert_eq!(
|
||
wire.points.len(),
|
||
44,
|
||
"point count must match the live capture exactly, confirming this IS the \
|
||
reported window"
|
||
);
|
||
assert_eq!(wire.terminus, CourseTerminus::Mouth);
|
||
|
||
// The invariant: the terminus point's containing Quarter cell, read
|
||
// from THIS SAME layer's own painted `morphology` array, must be
|
||
// OpenOcean or Lake.
|
||
let side = WindowGranularity::Quarter.cell_grid_side(clamped_n);
|
||
let half = side / 2;
|
||
let step_m = WindowGranularity::Quarter.spacing_m();
|
||
let center_world_m = (
|
||
center.0 as f64 * DISTRICT_M as f64,
|
||
center.1 as f64 * DISTRICT_M as f64,
|
||
);
|
||
let (tx, ty) = *wire.points.last().unwrap();
|
||
let col = ((tx as f64 - center_world_m.0) / step_m + half as f64).round() as i32;
|
||
let row = ((ty as f64 - center_world_m.1) / step_m + half as f64).round() as i32;
|
||
assert!(
|
||
row >= 0 && row < side && col >= 0 && col < side,
|
||
"terminus point ({tx},{ty}) must map to an in-window cell, got (row={row},col={col})"
|
||
);
|
||
let cell_idx = (row * side + col) as usize;
|
||
let painted = layer.morphology[cell_idx];
|
||
assert!(
|
||
painted == crate::simulation::generator::MorphologyZone::OpenOcean as u8
|
||
|| painted == crate::simulation::generator::MorphologyZone::Lake as u8,
|
||
"walk/paint DIVERGENCE: the resolved Mouth terminus ({tx},{ty}) landed in a cell \
|
||
painted with morphology discriminant {painted} (not OpenOcean=0 or Lake=1) — the \
|
||
termination walk's own water verdict must agree with the window's painted \
|
||
classification at the SAME position"
|
||
);
|
||
}
|
||
|
||
/// Clamped-window edge: `n = 1` is the minimum valid window (a single
|
||
/// district) — no panic, no empty output, exactly one cell per array.
|
||
#[test]
|
||
fn build_district_window_layer_handles_n_equals_one() {
|
||
let hm = window_test_hm();
|
||
let ta = window_test_ta(&hm);
|
||
let rn = window_test_river_network(&hm);
|
||
let params = window_test_params();
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(1).derive(SeedDomain::Body, 1);
|
||
|
||
let layer = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
(0, 0),
|
||
1,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
assert_eq!(layer.n, 1);
|
||
assert_eq!(layer.morphology.len(), 1);
|
||
assert_eq!(layer.elev_q.len(), 1);
|
||
assert_eq!(layer.temp_dc.len(), 1);
|
||
assert_eq!(layer.moisture_q.len(), 1);
|
||
assert_eq!(layer.vegetation.len(), 1);
|
||
assert_eq!(layer.glaciation.len(), 1);
|
||
}
|
||
|
||
/// Determinism spot-check (D-010, T-1123 precedent promoted to a real
|
||
/// test per the ticket): two full derive passes over the SAME window are
|
||
/// byte-identical, at a window size large enough to exercise many cells
|
||
/// (mirrors `aliveness_probe --render`'s own two-pass proof, now pinned
|
||
/// as a unit test rather than a probe-only demonstration).
|
||
#[test]
|
||
fn build_district_window_layer_two_passes_are_byte_identical() {
|
||
let hm = window_test_hm();
|
||
let ta = window_test_ta(&hm);
|
||
let rn = window_test_river_network(&hm);
|
||
let params = window_test_params();
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(7).derive(SeedDomain::Body, 3);
|
||
|
||
let n = 8u32;
|
||
let first = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
(3, -2),
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
let second = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
(3, -2),
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
assert_eq!(
|
||
first, second,
|
||
"two full derive passes over the same (center, n) must be byte-identical (D-010/D-227)"
|
||
);
|
||
}
|
||
|
||
/// T-1151 acceptance: the row-chunked `par_iter` window build
|
||
/// ([`build_district_window_layer`]) must be bit-identical to the
|
||
/// pre-parallel serial baseline ([`build_district_window_layer_serial`])
|
||
/// — same inputs, same output, exact row-major array ordering preserved.
|
||
/// Run at a window size large enough (16×16 = 256 cells) to actually
|
||
/// exercise multiple Rayon-dispatched rows, not just n=1.
|
||
#[test]
|
||
fn build_district_window_layer_parallel_matches_serial() {
|
||
let hm = window_test_hm();
|
||
let ta = window_test_ta(&hm);
|
||
let rn = window_test_river_network(&hm);
|
||
let params = window_test_params();
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(13).derive(SeedDomain::Body, 4);
|
||
|
||
let n = 16u32;
|
||
let center = (5, -9);
|
||
let parallel = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
center,
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
let serial = build_district_window_layer_serial(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta,
|
||
&rn,
|
||
center,
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
assert_eq!(
|
||
parallel, serial,
|
||
"row-chunked par_iter window build must be bit-identical to the serial baseline"
|
||
);
|
||
// Row-major ordering check, explicit (not just struct equality): the
|
||
// parallel path collects one Vec<WindowCell> per row via par_iter,
|
||
// which preserves index order (`par_iter().map(...).collect()` is
|
||
// order-preserving), but pin the ordering assumption directly too.
|
||
assert_eq!(parallel.morphology.len(), (n * n) as usize);
|
||
assert_eq!(parallel.center, center);
|
||
assert_eq!(parallel.n, n);
|
||
}
|
||
|
||
/// FULL-PATH determinism (PR #187 review — Tyre C3, binding, load-bearing
|
||
/// for save-file lineage under D-227): the test above reuses ONE `ta` for
|
||
/// both passes, which only proves `build_district_window_layer` (the
|
||
/// packer) is a pure function of its arguments — it says nothing about
|
||
/// whether re-running `run_layer1` itself (the D8 drainage pass +
|
||
/// `TerrainAnalysis::analyze`) is deterministic, which is exactly what the
|
||
/// production `DeriveWindow` path depends on (T-1137's `TerrainAnalysis`
|
||
/// re-derive / `TerrainAnalysisCache::get_or_derive` on a cache miss, and
|
||
/// `aliveness_probe --render`'s workaround before it).
|
||
///
|
||
/// This test runs `run_layer1` TWICE, independently, from the SAME
|
||
/// `(seed, heightmap)` inputs — no shared `ta` — and asserts the two
|
||
/// COMPLETE `DistrictWindowLayer` outputs (derive AND pack) are
|
||
/// byte-identical. D-227's save-file guarantee ("same seed/body/position →
|
||
/// same derived output, always") is only as strong as the weakest link in
|
||
/// that chain; this closes the gap the packer-only test left open.
|
||
#[test]
|
||
fn full_path_two_independent_run_layer1_passes_produce_identical_window() {
|
||
let hm = window_test_hm();
|
||
let params = window_test_params();
|
||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||
let seed = SeedChain::root(11).derive(SeedDomain::Body, 5);
|
||
let n = 6u32;
|
||
let center = (4, -1);
|
||
|
||
// Two INDEPENDENT calls to run_layer1 — each re-runs D8 drainage +
|
||
// TerrainAnalysis::analyze from scratch on the SAME heightmap, exactly
|
||
// mirroring what a cold TerrainAnalysisCache miss does on the real
|
||
// DeriveWindow path (or a second body eviction re-pay).
|
||
let (l1_pass1, ta_pass1) = crate::atlas::layer1::run_layer1(&hm);
|
||
let (l1_pass2, ta_pass2) = crate::atlas::layer1::run_layer1(&hm);
|
||
|
||
// Confirm the two independent TerrainAnalysis derivations themselves
|
||
// agree field-by-field — a precise failure signal if drainage/analyze
|
||
// ever introduces nondeterminism (unordered iteration, uninitialized
|
||
// memory, etc.) BEFORE the packer even runs.
|
||
assert_eq!(ta_pass1.ocean_mask, ta_pass2.ocean_mask);
|
||
assert_eq!(ta_pass1.lake_mask, ta_pass2.lake_mask);
|
||
assert_eq!(ta_pass1.water_dist, ta_pass2.water_dist);
|
||
assert_eq!(ta_pass1.slope_deg, ta_pass2.slope_deg);
|
||
assert_eq!(ta_pass1.elev_pct, ta_pass2.elev_pct);
|
||
// T-1184: two independent hydrology solves (each run_layer1 call
|
||
// solves fresh — no shared HydrologyResult) must also agree
|
||
// byte-for-byte, extending this test's own "weakest link in the
|
||
// determinism chain" rationale to the newest field on TerrainAnalysis.
|
||
let hydro1 = ta_pass1
|
||
.hydrology
|
||
.as_ref()
|
||
.expect("run_layer1 must populate hydrology");
|
||
let hydro2 = ta_pass2
|
||
.hydrology
|
||
.as_ref()
|
||
.expect("run_layer1 must populate hydrology");
|
||
assert_eq!(hydro1.elevation, hydro2.elevation);
|
||
assert_eq!(hydro1.filled, hydro2.filled);
|
||
|
||
// Now the FULL path: pack a DistrictWindowLayer from each independent
|
||
// TerrainAnalysis and confirm the complete served payload agrees.
|
||
let window_from_pass1 = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta_pass1,
|
||
&l1_pass1.river_network,
|
||
center,
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
let window_from_pass2 = build_district_window_layer(
|
||
seed,
|
||
"test_body",
|
||
¶ms,
|
||
&ta_pass2,
|
||
&l1_pass2.river_network,
|
||
center,
|
||
n,
|
||
&climate,
|
||
WindowGranularity::District,
|
||
0,
|
||
);
|
||
assert_eq!(
|
||
window_from_pass1, window_from_pass2,
|
||
"two independent run_layer1 derivations from the same (seed, heightmap) \
|
||
must pack to a byte-identical DistrictWindowLayer end to end (D-227)"
|
||
);
|
||
}
|
||
|
||
/// [`DistrictWindowCache`] insert/get round-trips, and a capacity-1 cache
|
||
/// evicts the oldest entry FIFO — mirroring `BodyWorldStateCache`'s own
|
||
/// `evicts_lru_on_overflow` precedent, adapted to this cache's
|
||
/// capacity-only insertion-order eviction (no access-recency tracking,
|
||
/// per the struct doc: D-227 means a cached window has no staleness to
|
||
/// track, only unbounded growth to bound).
|
||
#[test]
|
||
fn district_window_cache_insert_get_and_evict() {
|
||
let mut cache = DistrictWindowCache::new(2);
|
||
let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4, WindowGranularity::District, 0);
|
||
let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4, WindowGranularity::District, 0);
|
||
let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4, WindowGranularity::District, 0);
|
||
let mk = |center, n| DistrictWindowLayer {
|
||
center,
|
||
n,
|
||
granularity_v2: WindowGranularity::District,
|
||
min_wl_m: 0,
|
||
morphology: vec![0; (n * n) as usize],
|
||
elev_q: vec![0; (n * n) as usize],
|
||
temp_dc: vec![REGION_TEMP_NONE_DC; (n * n) as usize],
|
||
moisture_q: vec![0; (n * n) as usize],
|
||
vegetation: vec![0; (n * n) as usize],
|
||
glaciation: vec![0; (n * n) as usize],
|
||
courses: Vec::new(),
|
||
};
|
||
|
||
assert!(cache.get(&key_a).is_none());
|
||
cache.insert(key_a.clone(), mk((0, 0), 4));
|
||
cache.insert(key_b.clone(), mk((1, 1), 4));
|
||
assert_eq!(cache.len(), 2);
|
||
assert!(cache.get(&key_a).is_some());
|
||
assert!(cache.get(&key_b).is_some());
|
||
|
||
// Cache at capacity (2): inserting a third entry evicts key_a (oldest).
|
||
cache.insert(key_c.clone(), mk((2, 2), 4));
|
||
assert_eq!(cache.len(), 2);
|
||
assert!(
|
||
cache.get(&key_a).is_none(),
|
||
"key_a should have been evicted"
|
||
);
|
||
assert!(cache.get(&key_b).is_some());
|
||
assert!(cache.get(&key_c).is_some());
|
||
}
|
||
|
||
/// `handle_atlas_request`'s window branch clamps `window_n` server-side to
|
||
/// `[1, DISTRICT_WINDOW_MAX_N]` — a request claiming an oversized `n` on
|
||
/// the wire never reaches `build_district_window_layer` un-clamped. This
|
||
/// exercises the full request→submit→drain→cache→re-request loop with an
|
||
/// out-of-range `window_n`, confirming the CACHED layer (once the
|
||
/// background derive completes) carries the CLAMPED `n`, not the
|
||
/// requested one.
|
||
#[test]
|
||
fn handle_atlas_request_clamps_oversized_window_n() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
let oversized_req = AtlasLayerRequest {
|
||
body_id: "GJ1c".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((0, 0)),
|
||
window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
|
||
let resp = handle_atlas_request(
|
||
&oversized_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
// First request: window not yet cached → None, but a DeriveWindow
|
||
// must have been submitted (checked via the drain below).
|
||
assert!(resp.district_window.is_none());
|
||
|
||
// Wait for the Rayon DeriveWindow work item to complete.
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let window_completion = completions.into_iter().find_map(|c| {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "GJ1c" {
|
||
return Some(layer);
|
||
}
|
||
}
|
||
None
|
||
});
|
||
let layer = window_completion.expect("DeriveWindow must complete for GJ1c");
|
||
assert_eq!(
|
||
layer.n, DISTRICT_WINDOW_MAX_N,
|
||
"server must clamp window_n to DISTRICT_WINDOW_MAX_N, never trust the wire value"
|
||
);
|
||
}
|
||
|
||
/// **Contract-pinning test (PR #191 review, Tyre C1):** a quarter
|
||
/// (granularity=4) request for `n=32` echoes the WIRE-CAP-CLAMPED `n=16`,
|
||
/// not the requested 32 — `32² × 4² = 16,384` cells, 4x over
|
||
/// `WIRE_CAP_CELLS`. This is the exact scenario the review flagged as
|
||
/// silently breaking the client the moment T-1153 requests quarter at
|
||
/// n=32: the server echoes a DIFFERENT `n` than what was asked for, and
|
||
/// any client staleness guard comparing raw `_n` against the echo must
|
||
/// already know this will happen (see
|
||
/// `atlas_window_request.gd::_clamp_window_n_mirror()`, the client-side
|
||
/// fix landed alongside this test).
|
||
#[test]
|
||
fn quarter_n32_request_echoes_clamped_n16() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
let quarter_n32_req = AtlasLayerRequest {
|
||
body_id: "GJ1c".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((0, 0)),
|
||
window_n: 32,
|
||
window_granularity_v2: Some(WindowGranularity::Quarter),
|
||
window_min_wl_m: 0,
|
||
};
|
||
|
||
let resp = handle_atlas_request(
|
||
&quarter_n32_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert!(resp.district_window.is_none(), "first request — cache miss");
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let window_completion = completions.into_iter().find_map(|c| {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "GJ1c" {
|
||
return Some(layer);
|
||
}
|
||
}
|
||
None
|
||
});
|
||
let layer = window_completion.expect("DeriveWindow must complete for GJ1c");
|
||
assert_eq!(
|
||
layer.granularity_v2,
|
||
WindowGranularity::Quarter,
|
||
"granularity must echo back as requested (4 is within budget on its own)"
|
||
);
|
||
assert_eq!(
|
||
layer.n, 16,
|
||
"a quarter n=32 request must echo the wire-cap-clamped n=16, not the requested 32"
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// clamp_window_n (T-1150)
|
||
// -------------------------------------------------------------------
|
||
|
||
/// District granularity: the per-axis DISTRICT_WINDOW_MAX_N cap alone
|
||
/// governs (64² × 1² = 4,096 = WIRE_CAP_CELLS exactly, so the cap is
|
||
/// never tighter than DISTRICT_WINDOW_MAX_N at granularity 1).
|
||
#[test]
|
||
fn clamp_window_n_district_granularity_uses_per_axis_cap() {
|
||
assert_eq!(
|
||
clamp_window_n(DISTRICT_WINDOW_MAX_N * 10, WINDOW_GRANULARITY_DISTRICT),
|
||
DISTRICT_WINDOW_MAX_N
|
||
);
|
||
assert_eq!(clamp_window_n(32, WINDOW_GRANULARITY_DISTRICT), 32);
|
||
assert_eq!(clamp_window_n(0, WINDOW_GRANULARITY_DISTRICT), 1);
|
||
}
|
||
|
||
/// Quarter granularity: the wire-size ceiling bites BEFORE the per-axis
|
||
/// cap — a request for n=64 at granularity 4 would derive 256×256=65,536
|
||
/// cells (16x over budget), so it must clamp down to n=16
|
||
/// (16² × 4² = 4,096 = WIRE_CAP_CELLS exactly — matching the design
|
||
/// doc's §3 worked example, "Quarter, capped to same cell budget
|
||
/// (n≈16 districts across)"), never to the raw DISTRICT_WINDOW_MAX_N=64.
|
||
#[test]
|
||
fn clamp_window_n_quarter_granularity_uses_wire_cap_not_per_axis_cap() {
|
||
let clamped = clamp_window_n(DISTRICT_WINDOW_MAX_N, WINDOW_GRANULARITY_QUARTER);
|
||
assert_eq!(
|
||
clamped, 16,
|
||
"quarter granularity must clamp n to keep (n*granularity)^2 <= WIRE_CAP_CELLS"
|
||
);
|
||
assert!(
|
||
clamped * clamped * WINDOW_GRANULARITY_QUARTER * WINDOW_GRANULARITY_QUARTER
|
||
<= WIRE_CAP_CELLS,
|
||
"clamped cell count must never exceed WIRE_CAP_CELLS"
|
||
);
|
||
}
|
||
|
||
/// A small requested `n` at quarter granularity is left unclamped when it
|
||
/// already fits the budget (the cap must not be a flat floor/ceiling
|
||
/// substitution — only trims when the request would actually overflow).
|
||
#[test]
|
||
fn clamp_window_n_quarter_granularity_leaves_small_n_unclamped() {
|
||
assert_eq!(clamp_window_n(8, WINDOW_GRANULARITY_QUARTER), 8);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// clamp_window_n_v2 (T-1152; PR #192 review — Hoshe, coordinator ruling
|
||
// 2026-07-22: test 2 reframed per the brute-force finding that the
|
||
// Region halving loop is unreachable at the CURRENT constants — see
|
||
// clamp_window_n_v2's doc comment for the full rationale)
|
||
// -------------------------------------------------------------------
|
||
|
||
/// The exact boundary: `n = DISTRICT_WINDOW_MAX_N_REGION` (6,400) is the
|
||
/// largest per-axis-legal `n`, and it lands EXACTLY on the wire-size
|
||
/// ceiling (`cell_grid_side(6400) = 64 = sqrt(WIRE_CAP_CELLS)`,
|
||
/// `64² = 4,096 = WIRE_CAP_CELLS`) — uncontested, meaning the request is
|
||
/// NOT further reduced by the halving loop; the per-axis clamp alone is
|
||
/// already exact at this boundary.
|
||
#[test]
|
||
fn clamp_window_n_v2_region_exact_boundary_n6400_uncontested() {
|
||
let result = clamp_window_n_v2(DISTRICT_WINDOW_MAX_N_REGION, WindowGranularity::Region);
|
||
assert_eq!(
|
||
result, DISTRICT_WINDOW_MAX_N_REGION,
|
||
"n=6400 must pass through unmodified — it already lands exactly on the ceiling"
|
||
);
|
||
let side = WindowGranularity::Region.cell_grid_side(result) as u32;
|
||
assert_eq!(
|
||
side * side,
|
||
WIRE_CAP_CELLS,
|
||
"n=6400's cell_grid_side must land EXACTLY on WIRE_CAP_CELLS, not under or over it"
|
||
);
|
||
}
|
||
|
||
/// **Reframed per the coordinator's 2026-07-22 ruling (PR #192 review —
|
||
/// Hoshe).** The originally-briefed name/shape
|
||
/// (`clamp_window_n_v2_region_halving_loop_fires_above_boundary`, e.g.
|
||
/// n=6450) does not hold: `raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION)`
|
||
/// runs BEFORE the halving loop's condition is ever checked, so any
|
||
/// `raw_n > DISTRICT_WINDOW_MAX_N_REGION` is clamped to exactly 6,400 —
|
||
/// the SAME uncontested boundary the test above proves — before
|
||
/// `cell_grid_side` ever sees the raw value. A brute-force sweep (done
|
||
/// by hand before writing this test, see `clamp_window_n_v2`'s doc
|
||
/// comment) confirms `cell_grid_side(n)` never exceeds `sqrt(WIRE_CAP_CELLS)`
|
||
/// for ANY `n` in `[1, DISTRICT_WINDOW_MAX_N_REGION]` — so the halving
|
||
/// loop is unreachable at the CURRENT constant derivation, not a bug to
|
||
/// manufacture a test around (coordinator's option 1, not option 2).
|
||
///
|
||
/// This test proves the ACTUAL property: the per-axis cap ALONE already
|
||
/// satisfies the wire-size ceiling for every reachable input, and pins
|
||
/// the loop's current no-op status explicitly — swept across
|
||
/// `[1, 2 × DISTRICT_WINDOW_MAX_N_REGION]` (double the legal range, so
|
||
/// wildly-oversized wire values are covered too, never trusting the
|
||
/// wire). If a FUTURE constant change (a new rung, a `WIRE_CAP_CELLS`
|
||
/// retune) ever makes the loop fire, the second assertion below breaks
|
||
/// LOUDLY — forcing a deliberate look rather than a silent behavior
|
||
/// change (exactly the safety-net role the loop exists for).
|
||
#[test]
|
||
fn clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs() {
|
||
for raw_n in 1..=(2 * DISTRICT_WINDOW_MAX_N_REGION) {
|
||
let result = clamp_window_n_v2(raw_n, WindowGranularity::Region);
|
||
let side = WindowGranularity::Region.cell_grid_side(result) as u32;
|
||
assert!(
|
||
side * side <= WIRE_CAP_CELLS,
|
||
"raw_n={raw_n}: clamped result {result} (side {side}) exceeds WIRE_CAP_CELLS"
|
||
);
|
||
assert_eq!(
|
||
result,
|
||
raw_n.clamp(1, DISTRICT_WINDOW_MAX_N_REGION),
|
||
"raw_n={raw_n}: the halving loop must be a no-op at current constants — \
|
||
the per-axis clamp alone must already be the final answer"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `District`/`Quarter` through `clamp_window_n_v2` must be BYTE-IDENTICAL
|
||
/// to the legacy `clamp_window_n` for every input either variant can
|
||
/// legally carry — `clamp_window_n_v2` is documented as delegating to the
|
||
/// legacy function unchanged for these two rungs, this pins that claim
|
||
/// with a sweep rather than a handful of spot values.
|
||
#[test]
|
||
fn clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter() {
|
||
// Sweep well past DISTRICT_WINDOW_MAX_N so the "never trust the wire"
|
||
// oversized-input case is covered too, not just in-range values.
|
||
for raw_n in 0..=(DISTRICT_WINDOW_MAX_N * 3) {
|
||
assert_eq!(
|
||
clamp_window_n_v2(raw_n, WindowGranularity::District),
|
||
clamp_window_n(raw_n, WINDOW_GRANULARITY_DISTRICT),
|
||
"District: clamp_window_n_v2 must match clamp_window_n exactly at raw_n={raw_n}"
|
||
);
|
||
assert_eq!(
|
||
clamp_window_n_v2(raw_n, WindowGranularity::Quarter),
|
||
clamp_window_n(raw_n, WINDOW_GRANULARITY_QUARTER),
|
||
"Quarter: clamp_window_n_v2 must match clamp_window_n exactly at raw_n={raw_n}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// quantize_min_wl_m (T-1150, PR #191 review — Hoshe 1 / Tyre C3, design doc §5)
|
||
// -------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn quantize_min_wl_m_exact_band_values_are_stable() {
|
||
for &band in &MIN_WL_BANDS_M {
|
||
assert_eq!(quantize_min_wl_m(band as u32), band as u32);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn quantize_min_wl_m_zero_stays_zero() {
|
||
assert_eq!(quantize_min_wl_m(0), 0);
|
||
}
|
||
|
||
/// A value nearer to 0 than to the finest real octave band (T-1162: now
|
||
/// 1,024, the Quarter-rung floor) snaps to 0 (no cutoff) — the band set
|
||
/// includes 0 as a real, selectable band, not just a special-cased default.
|
||
#[test]
|
||
fn quantize_min_wl_m_small_value_snaps_to_zero_band() {
|
||
assert_eq!(quantize_min_wl_m(500), 0);
|
||
}
|
||
|
||
/// A value between two real octave bands snaps to the NEAREST one, not
|
||
/// always up or always down.
|
||
#[test]
|
||
fn quantize_min_wl_m_mid_value_snaps_to_nearest_band() {
|
||
// Between 4,096 and 8,192: 5,000 is nearer 4,096 (dist 904 vs 3,192).
|
||
assert_eq!(quantize_min_wl_m(5_000), 4_096);
|
||
// 7,500 is nearer 8,192 (dist 692 vs 3,404).
|
||
assert_eq!(quantize_min_wl_m(7_500), 8_192);
|
||
}
|
||
|
||
/// T-1162: a value between the new 1,024 band and 0 snaps to whichever is
|
||
/// nearer — exercises the new finest band specifically, not just the
|
||
/// pre-existing four.
|
||
#[test]
|
||
fn quantize_min_wl_m_snaps_to_new_quarter_floor_band() {
|
||
// Nearer 1,024 than 0 (dist 224 vs 800).
|
||
assert_eq!(quantize_min_wl_m(800), 1_024);
|
||
// Nearer 1,024 than 4,096 (dist 476 vs 2,596).
|
||
assert_eq!(quantize_min_wl_m(1_500), 1_024);
|
||
}
|
||
|
||
/// A value far above the coarsest band snaps to the coarsest band, never
|
||
/// panics or overflows — quantization must be a TOTAL function over all
|
||
/// u32 input (never trust the wire).
|
||
#[test]
|
||
fn quantize_min_wl_m_huge_value_snaps_to_coarsest_band() {
|
||
assert_eq!(quantize_min_wl_m(u32::MAX), 32_768);
|
||
assert_eq!(quantize_min_wl_m(1_000_000), 32_768);
|
||
}
|
||
|
||
/// The mandatory §5 aliasing-closing test: two requests differing only in
|
||
/// an UNQUANTIZED `min_wl_m` that both fall in the SAME band must share
|
||
/// ONE cache entry, not two — this is the exact gap §5 flags ("same key,
|
||
/// different min_wl, would silently collide" becomes "same key, same
|
||
/// quantized min_wl, correctly coalesce").
|
||
#[test]
|
||
fn two_requests_in_same_min_wl_band_share_one_cache_entry() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("BandBody", 6371.0);
|
||
let queue = GenerationQueue::with_threads(2);
|
||
|
||
// Both values are nearer 4,096 than any other band (4,000 and 4,300
|
||
// both round to 4,096 — see the mid-value test above for the
|
||
// distance math), so they must land in the SAME quantized band.
|
||
let req_a = AtlasLayerRequest {
|
||
body_id: "BandBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((10, -5)),
|
||
window_n: 4,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 4_000,
|
||
};
|
||
let req_b = AtlasLayerRequest {
|
||
body_id: "BandBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((10, -5)),
|
||
window_n: 4,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 4_300,
|
||
};
|
||
|
||
handle_atlas_request(
|
||
&req_a,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
handle_atlas_request(
|
||
&req_b,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
for c in completions {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "BandBody" {
|
||
window_cache.insert(
|
||
(
|
||
body_id,
|
||
layer.center,
|
||
layer.n,
|
||
layer.granularity_v2,
|
||
layer.min_wl_m,
|
||
),
|
||
*layer,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
1,
|
||
"two requests in the SAME quantized min_wl_m band at identical \
|
||
(body, center, n, granularity) must share ONE cache entry, not two"
|
||
);
|
||
|
||
// Re-request both — each must hit the SAME cached entry and echo the
|
||
// QUANTIZED band (4,096), not either raw wire value.
|
||
let resp_a = handle_atlas_request(
|
||
&req_a,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
let resp_b = handle_atlas_request(
|
||
&req_b,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
let layer_a = resp_a.district_window.expect("req_a must hit the cache");
|
||
let layer_b = resp_b.district_window.expect("req_b must hit the cache");
|
||
assert_eq!(layer_a.min_wl_m, 4_096, "echo must be the QUANTIZED band");
|
||
assert_eq!(layer_b.min_wl_m, 4_096, "echo must be the QUANTIZED band");
|
||
assert_eq!(
|
||
layer_a, layer_b,
|
||
"both requests must resolve to the identical cached layer"
|
||
);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// normalize_window_center (T-1142 — letterbox-click out-of-range bug)
|
||
// -------------------------------------------------------------------
|
||
|
||
/// A small-body fixture (500 km radius) whose bounds are hand-checkable:
|
||
/// `districts_per_circumference = round(TAU*500_000/2048) = 1534`,
|
||
/// `half_meridian_districts = round(PI*500_000/2048/2) = 383`. The
|
||
/// literal column/row this fixture uses (`12276`, `3021`) are the exact
|
||
/// values the reported T-1142 letterbox-click bug sent — at THIS radius
|
||
/// they genuinely overflow both bounds (at Earth radius, coincidentally,
|
||
/// they wouldn't — the bounds are tens of thousands of districts wide),
|
||
/// so this is a faithful small-body reproduction, not just an
|
||
/// arbitrarily-chosen out-of-range pair.
|
||
fn small_body_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(500.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Out-of-range COLUMN wraps (longitude is periodic) to its in-range
|
||
/// canonical twin via `rem_euclid` — mirroring `derive_district`'s own
|
||
/// `(wx / circumference_m).rem_euclid(1.0)` forward map.
|
||
#[test]
|
||
fn normalize_window_center_wraps_out_of_range_column() {
|
||
let params = small_body_params();
|
||
// districts_per_circumference = 1534 (hand-computed above).
|
||
// 12276 rem_euclid 1534 = 4 (12276 = 8*1534 + 4).
|
||
assert_eq!(
|
||
12276_i32.rem_euclid(1534),
|
||
4,
|
||
"sanity: the hand-computed wrap"
|
||
);
|
||
let (nx, ny) = normalize_window_center(¶ms, (12276, 0));
|
||
assert_eq!(
|
||
nx, 4,
|
||
"out-of-range column wraps to its canonical in-range twin"
|
||
);
|
||
assert_eq!(ny, 0, "an in-range row is untouched");
|
||
|
||
// The canonical twin normalizes to itself (idempotent).
|
||
let (nx2, _) = normalize_window_center(¶ms, (4, 0));
|
||
assert_eq!(nx2, 4);
|
||
|
||
// Negative columns wrap too (rem_euclid, not truncating rem) —
|
||
// longitude has no sign discontinuity.
|
||
let (nx3, _) = normalize_window_center(¶ms, (-1, 0));
|
||
assert_eq!(
|
||
nx3, 1533,
|
||
"negative column wraps to the top of the range, not a negative remainder"
|
||
);
|
||
}
|
||
|
||
/// Beyond-pole ROW clamps (latitude terminates, does not wrap) to
|
||
/// `±half_meridian_districts` — mirroring `derive_district`'s own
|
||
/// `(wy / meridian_m).clamp(-0.5, 0.5)` forward map. This is the
|
||
/// asymmetry the coordinator's fix explicitly calls out: columns wrap,
|
||
/// rows clamp — never the other way around.
|
||
#[test]
|
||
fn normalize_window_center_clamps_beyond_pole_row() {
|
||
let params = small_body_params();
|
||
// half_meridian_districts = 383 (hand-computed above).
|
||
let (_, ny) = normalize_window_center(¶ms, (0, 3021));
|
||
assert_eq!(
|
||
ny, 383,
|
||
"beyond-pole row clamps to the pole boundary, not wraps"
|
||
);
|
||
|
||
let (_, ny_neg) = normalize_window_center(¶ms, (0, -9000));
|
||
assert_eq!(ny_neg, -383, "clamping is symmetric at both poles");
|
||
|
||
// A row exactly at the boundary is untouched.
|
||
let (_, ny_boundary) = normalize_window_center(¶ms, (0, 383));
|
||
assert_eq!(ny_boundary, 383);
|
||
}
|
||
|
||
/// An in-range center (well inside both bounds) is returned UNCHANGED —
|
||
/// normalization must be a no-op for the overwhelming common case (every
|
||
/// legitimate click), not just a defensive clamp that happens to also
|
||
/// preserve valid input.
|
||
#[test]
|
||
fn normalize_window_center_leaves_in_range_center_unchanged() {
|
||
let params = small_body_params();
|
||
let center = (100, -50);
|
||
assert_eq!(normalize_window_center(¶ms, center), center);
|
||
|
||
// (0, 0) — the origin — is always in range regardless of body size.
|
||
assert_eq!(normalize_window_center(¶ms, (0, 0)), (0, 0));
|
||
}
|
||
|
||
/// No-radius bodies (tiny test-body fallback, `body_radius_km: None`) get
|
||
/// IDENTITY — `derive_district`'s own no-radius branch has no
|
||
/// wrap/clamp-in-district-space concept (see the function doc); an
|
||
/// extreme center here is out of this fix's scope by design, not an
|
||
/// oversight.
|
||
#[test]
|
||
fn normalize_window_center_no_radius_is_identity() {
|
||
let params = BodyParams::default(); // body_radius_km: None
|
||
let extreme = (999_999, -999_999);
|
||
assert_eq!(normalize_window_center(¶ms, extreme), extreme);
|
||
}
|
||
|
||
/// End-to-end (the coordinator's core ask): an out-of-range
|
||
/// `window_center` and its already-normalized twin, requested through the
|
||
/// REAL `handle_atlas_request` path, land in the SAME `DistrictWindowCache`
|
||
/// entry and produce a byte-identical `DistrictWindowLayer` — normalization
|
||
/// happens BEFORE the cache key is built, so an insane request and its
|
||
/// sane twin never diverge into separate cache entries (the bug this fix
|
||
/// closes: the insane request was cached STANDALONE).
|
||
#[test]
|
||
fn insane_and_sane_twin_requests_share_one_cache_entry() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("SmallMoon", 500.0);
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
// The insane request — the reported T-1142 letterbox-click values.
|
||
let insane_req = AtlasLayerRequest {
|
||
body_id: "SmallMoon".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((12276, 3021)),
|
||
window_n: 4,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
let resp1 = handle_atlas_request(
|
||
&insane_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert!(
|
||
resp1.district_window.is_none(),
|
||
"first request — cache miss, DeriveWindow submitted"
|
||
);
|
||
|
||
// Wait for the background derive to complete and drain it into the cache
|
||
// (mirrors handle_atlas_request_clamps_oversized_window_n's pattern).
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
for c in completions {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "SmallMoon" {
|
||
window_cache.insert(
|
||
(
|
||
body_id,
|
||
layer.center,
|
||
layer.n,
|
||
layer.granularity_v2,
|
||
layer.min_wl_m,
|
||
),
|
||
*layer,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Exactly ONE entry must exist in the window cache after the insane
|
||
// request's derive completes — normalization means it was keyed on
|
||
// the canonical (4, 383), not the raw (12276, 3021).
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
1,
|
||
"the insane request's derive must be cached under its NORMALIZED key"
|
||
);
|
||
|
||
// The "sane twin" — the already-normalized canonical center — hits
|
||
// the SAME cache entry the insane request just populated.
|
||
let sane_twin_req = AtlasLayerRequest {
|
||
body_id: "SmallMoon".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((4, 383)), // the hand-computed canonical twin
|
||
window_n: 4,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
let resp2 = handle_atlas_request(
|
||
&sane_twin_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
let twin_layer = resp2.district_window.expect(
|
||
"the sane twin must hit the cache the insane request populated — no new derive needed",
|
||
);
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
1,
|
||
"the sane twin must NOT create a second cache entry"
|
||
);
|
||
|
||
// Re-request the ORIGINAL insane center too — it must ALSO now hit
|
||
// the same populated cache entry (both requests normalize to the
|
||
// same key).
|
||
let resp3 = handle_atlas_request(
|
||
&insane_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
3,
|
||
test_conn_id(),
|
||
);
|
||
let insane_layer_second_try = resp3
|
||
.district_window
|
||
.expect("the insane request, re-requested, must ALSO hit the shared cache entry");
|
||
|
||
assert_eq!(
|
||
twin_layer, insane_layer_second_try,
|
||
"the insane request and its sane twin must resolve to a BYTE-IDENTICAL layer"
|
||
);
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
1,
|
||
"still exactly one entry — neither re-request created a second one"
|
||
);
|
||
}
|
||
|
||
/// **MANDATORY aliasing regression (T-1150, design doc §3's flagged
|
||
/// aliasing risk):** a granularity-4 (quarter) request and a
|
||
/// granularity-1 (district) request at the IDENTICAL `(body, center, n)`
|
||
/// must produce DISTINCT `DistrictWindowCache` entries and correct
|
||
/// per-granularity payloads — never silently alias onto the same slot
|
||
/// and serve one rung's data for the other's request.
|
||
#[test]
|
||
fn granularity_4_and_granularity_1_requests_produce_distinct_cache_entries() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("AliasBody", 6371.0);
|
||
// 3 threads: BOTH DeriveWindow items (district + quarter) need to
|
||
// dispatch concurrently with the AnalyzeBody item the first request's
|
||
// whole-body cache miss also enqueues (handle_atlas_request always
|
||
// fires an AnalyzeBody alongside the window derive on a cold body) —
|
||
// 2 threads left one DeriveWindow stuck behind AnalyzeBody within the
|
||
// single drain_completions() call below.
|
||
let queue = GenerationQueue::with_threads(3);
|
||
|
||
let center = Some((10, -5));
|
||
let n = 4u32;
|
||
|
||
let district_req = AtlasLayerRequest {
|
||
body_id: "AliasBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: center,
|
||
window_n: n,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
let quarter_req = AtlasLayerRequest {
|
||
body_id: "AliasBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: center,
|
||
window_n: n,
|
||
window_granularity_v2: Some(WindowGranularity::Quarter),
|
||
window_min_wl_m: 0,
|
||
};
|
||
|
||
// Fire both requests — same (body, center, n), different granularity.
|
||
handle_atlas_request(
|
||
&district_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
handle_atlas_request(
|
||
&quarter_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
for c in completions {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "AliasBody" {
|
||
window_cache.insert(
|
||
(
|
||
body_id,
|
||
layer.center,
|
||
layer.n,
|
||
layer.granularity_v2,
|
||
layer.min_wl_m,
|
||
),
|
||
*layer,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
2,
|
||
"district and quarter requests at the SAME (body, center, n) must occupy \
|
||
TWO distinct cache entries, not alias onto one"
|
||
);
|
||
|
||
// Re-request both — each must now hit ITS OWN cached entry and return
|
||
// the CORRECT per-granularity payload (not the other rung's data).
|
||
let district_resp = handle_atlas_request(
|
||
&district_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
let quarter_resp = handle_atlas_request(
|
||
&quarter_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
|
||
let district_layer = district_resp
|
||
.district_window
|
||
.expect("district request must hit its own cached entry");
|
||
let quarter_layer = quarter_resp
|
||
.district_window
|
||
.expect("quarter request must hit its own cached entry");
|
||
|
||
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
|
||
assert_eq!(quarter_layer.granularity_v2, WindowGranularity::Quarter);
|
||
// n echoes the DISTRICT extent unchanged at both granularities
|
||
// (design doc §2: "the window's n stays the DISTRICT extent").
|
||
assert_eq!(district_layer.n, n);
|
||
assert_eq!(quarter_layer.n, n);
|
||
// The derived CELL GRID differs: n×n at district, (4n)×(4n) at quarter.
|
||
assert_eq!(district_layer.morphology.len(), (n * n) as usize);
|
||
assert_eq!(
|
||
quarter_layer.morphology.len(),
|
||
(n * WINDOW_GRANULARITY_QUARTER * n * WINDOW_GRANULARITY_QUARTER) as usize
|
||
);
|
||
// Correct per-granularity payload, not the other rung's data reused:
|
||
// the quarter grid must show finer per-cell VARIATION than a naive
|
||
// 4x-repeat of the district grid would (Option B — full
|
||
// reclassification at 512 m, not a coarser-cell interpolation).
|
||
let quarter_elev_range = {
|
||
let min = quarter_layer.elev_q.iter().min().copied().unwrap_or(0);
|
||
let max = quarter_layer.elev_q.iter().max().copied().unwrap_or(0);
|
||
max - min
|
||
};
|
||
assert!(
|
||
quarter_elev_range > 0,
|
||
"quarter-granularity window must show real sub-district elevation \
|
||
variation, not a blocky repeat of the district cells"
|
||
);
|
||
}
|
||
|
||
/// **MANDATORY aliasing regression for the T-1152 coarser rung** — the
|
||
/// SAME discipline `granularity_4_and_granularity_1_requests_produce_distinct_cache_entries`
|
||
/// established for finer-than-district rungs, extended to `Region`
|
||
/// (T-1150 design doc §3's aliasing risk, generalized by the T-1152
|
||
/// wire-contract note: "mirror the T-1150 aliasing tests for at least
|
||
/// one coarser rung"). A `Region`-granularity request and a
|
||
/// `District`-granularity request at the IDENTICAL `(body, center, n)`
|
||
/// must produce DISTINCT cache entries, distinct payload shapes, and the
|
||
/// clamp/echo contract must hold at the coarse rung too (PR #191 C1
|
||
/// lesson generalized: the client's mirror of `clamp_window_n_v2` MUST
|
||
/// be derivable from the same constants this test exercises).
|
||
#[test]
|
||
fn region_and_district_requests_produce_distinct_cache_entries() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("OrbitalAliasBody", 6371.0);
|
||
// See the district/quarter alias test above for the thread-count
|
||
// rationale (AnalyzeBody + two DeriveWindow items must all be able to
|
||
// dispatch concurrently within one drain_completions() call).
|
||
let queue = GenerationQueue::with_threads(3);
|
||
|
||
let center = Some((10, -5));
|
||
let n = 4u32;
|
||
|
||
let district_req = AtlasLayerRequest {
|
||
body_id: "OrbitalAliasBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: center,
|
||
window_n: n,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
let region_req = AtlasLayerRequest {
|
||
body_id: "OrbitalAliasBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: center,
|
||
window_n: n,
|
||
window_granularity_v2: Some(WindowGranularity::Region),
|
||
window_min_wl_m: 0,
|
||
};
|
||
|
||
handle_atlas_request(
|
||
&district_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
handle_atlas_request(
|
||
®ion_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
for c in completions {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "OrbitalAliasBody" {
|
||
window_cache.insert(
|
||
(
|
||
body_id,
|
||
layer.center,
|
||
layer.n,
|
||
layer.granularity_v2,
|
||
layer.min_wl_m,
|
||
),
|
||
*layer,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
window_cache.len(),
|
||
2,
|
||
"district and region requests at the SAME (body, center, n) must occupy \
|
||
TWO distinct cache entries, not alias onto one"
|
||
);
|
||
|
||
let district_resp = handle_atlas_request(
|
||
&district_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
let region_resp = handle_atlas_request(
|
||
®ion_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
|
||
let district_layer = district_resp
|
||
.district_window
|
||
.expect("district request must hit its own cached entry");
|
||
let region_layer = region_resp
|
||
.district_window
|
||
.expect("region request must hit its own cached entry");
|
||
|
||
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
|
||
assert_eq!(region_layer.granularity_v2, WindowGranularity::Region);
|
||
assert_ne!(region_layer.granularity_v2, district_layer.granularity_v2);
|
||
|
||
// n echoes the DISTRICT extent unchanged (design doc §2), same as
|
||
// every other rung — the derived CELL GRID is what differs.
|
||
assert_eq!(district_layer.n, n);
|
||
assert_eq!(region_layer.n, n);
|
||
assert_eq!(district_layer.morphology.len(), (n * n) as usize);
|
||
// n=4 districts is far under one region's 100-district side, so
|
||
// cell_grid_side rounds down to the minimum 1x1 region cell —
|
||
// exercising the "region divides, doesn't multiply" cell-count
|
||
// relationship WindowGranularity::cell_grid_side documents.
|
||
assert_eq!(
|
||
region_layer.morphology.len(),
|
||
1,
|
||
"n=4 districts is far under one region's 100-district side; \
|
||
cell_grid_side must round down to a single region cell, not zero \
|
||
and not a district-sized grid"
|
||
);
|
||
}
|
||
|
||
/// The clamp/echo contract at the coarse (Region) rung (T-1152, the PR
|
||
/// #191 C1 lesson generalized): a region request whose `n` would derive
|
||
/// MORE than `sqrt(WIRE_CAP_CELLS)` region cells across must clamp `n`
|
||
/// down and echo the CLAMPED value — Stig's client-side mirror of
|
||
/// `clamp_window_n_v2` must be derivable from
|
||
/// `DISTRICT_WINDOW_MAX_N_REGION`/`WIRE_CAP_CELLS`/`DISTRICTS_PER_REGION`
|
||
/// alone, exactly as `_clamp_window_n_mirror()` already mirrors
|
||
/// `clamp_window_n` for the finer rungs.
|
||
#[test]
|
||
fn region_request_oversized_n_clamps_and_echoes_clamped_n() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("OrbitalCapBody", 6371.0);
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
// Far over DISTRICT_WINDOW_MAX_N_REGION (6,400) — must clamp, never
|
||
// trust the wire (same discipline as the district-rung oversized-n test).
|
||
let oversized_region_req = AtlasLayerRequest {
|
||
body_id: "OrbitalCapBody".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((0, 0)),
|
||
window_n: DISTRICT_WINDOW_MAX_N_REGION * 10,
|
||
window_granularity_v2: Some(WindowGranularity::Region),
|
||
window_min_wl_m: 0,
|
||
};
|
||
|
||
let resp = handle_atlas_request(
|
||
&oversized_region_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert!(resp.district_window.is_none(), "first request — cache miss");
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let window_completion = completions.into_iter().find_map(|c| {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "OrbitalCapBody" {
|
||
return Some(layer);
|
||
}
|
||
}
|
||
None
|
||
});
|
||
let layer = window_completion.expect("DeriveWindow must complete for OrbitalCapBody");
|
||
assert_eq!(layer.granularity_v2, WindowGranularity::Region);
|
||
assert!(
|
||
layer.n <= DISTRICT_WINDOW_MAX_N_REGION,
|
||
"echoed n must be clamped to DISTRICT_WINDOW_MAX_N_REGION, not the raw oversized value"
|
||
);
|
||
let side = layer.granularity_v2.cell_grid_side(layer.n);
|
||
assert!(
|
||
(side as u32) * (side as u32) <= WIRE_CAP_CELLS,
|
||
"clamped cell count must never exceed WIRE_CAP_CELLS at Region granularity either"
|
||
);
|
||
}
|
||
|
||
/// The echoed `center` on `DistrictWindowLayer` is the NORMALIZED value,
|
||
/// not the raw wire value — the client's D-227 staleness guard (D-226
|
||
/// T-1124 amendment §2) must see what was ACTUALLY derived, so it can
|
||
/// correctly match this response against its own (now also normalized,
|
||
/// per the T-1142 fix note to the client team) cache key.
|
||
#[test]
|
||
fn echoed_center_is_the_normalized_value_not_the_raw_wire_value() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) =
|
||
resolver_and_params_reader_with_radius("SmallMoon2", 500.0);
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
let insane_req = AtlasLayerRequest {
|
||
body_id: "SmallMoon2".to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: Some((12276, 3021)), // raw, out-of-range
|
||
window_n: 4,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
};
|
||
handle_atlas_request(
|
||
&insane_req,
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let window_completion = completions.into_iter().find_map(|c| {
|
||
if let GenCompletion::WindowDerived { body_id, layer } = c {
|
||
if body_id == "SmallMoon2" {
|
||
return Some(layer);
|
||
}
|
||
}
|
||
None
|
||
});
|
||
let layer = window_completion.expect("DeriveWindow must complete");
|
||
assert_eq!(
|
||
layer.center,
|
||
(4, 383),
|
||
"the completed/echoed layer.center is the NORMALIZED value, not the raw (12276, 3021)"
|
||
);
|
||
assert_ne!(
|
||
layer.center,
|
||
(12276, 3021),
|
||
"the raw out-of-range wire value must never be echoed back"
|
||
);
|
||
}
|
||
|
||
/// `serve_district_window` returns `None` (no window requested) when the
|
||
/// request carries no `window_center` — the common case, and the ONLY
|
||
/// path every pre-T-1137 caller takes (wire back-compat: an old client's
|
||
/// `{body_id, up_to}` frame decodes with `window_center: None` via
|
||
/// `#[serde(default)]`).
|
||
#[test]
|
||
fn handle_atlas_request_no_window_center_leaves_district_window_none() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
let (_db, resolver) = empty_resolver();
|
||
let queue = GenerationQueue::with_threads(1);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("GJ1c"), // window_center: None
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
None,
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert!(resp.district_window.is_none());
|
||
assert!(
|
||
window_cache.is_empty(),
|
||
"no window requested → no DeriveWindow submitted, cache stays empty"
|
||
);
|
||
}
|
||
|
||
/// `AtlasLayerResponse.district_window` survives a MessagePack round trip
|
||
/// (mirrors the existing `atlas_layer_response_with_new_layers_round_trips_msgpack`
|
||
/// precedent) — the wire shape every field the amendment specifies:
|
||
/// echoed `center`/`n`, all six parallel arrays including the
|
||
/// `REGION_TEMP_NONE_DC` sentinel and `VegetationClass::Marine = 6`.
|
||
#[test]
|
||
fn district_window_layer_round_trips_msgpack_inside_response() {
|
||
let window = DistrictWindowLayer {
|
||
center: (10, -5),
|
||
n: 2,
|
||
granularity_v2: WindowGranularity::District,
|
||
min_wl_m: 0,
|
||
morphology: vec![0, 8, 14, 16],
|
||
elev_q: vec![0, 45, 98, 60],
|
||
temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80],
|
||
moisture_q: vec![90, 55, 0, 100],
|
||
vegetation: vec![6, 3, 0, 5], // includes Marine = 6
|
||
glaciation: vec![0, 0, 4, 1],
|
||
courses: Vec::new(),
|
||
};
|
||
let resp = AtlasLayerResponse {
|
||
body_id: "GJ1c".into(),
|
||
status: AtlasLayerStatus::Ready,
|
||
layer1: None,
|
||
district_grid: None,
|
||
road_graph: None,
|
||
settlements: None,
|
||
region_grid: None,
|
||
district_window: Some(window.clone()),
|
||
quarter_footprints: None,
|
||
};
|
||
|
||
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
||
let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode");
|
||
|
||
let dw = decoded
|
||
.district_window
|
||
.expect("district_window survives round trip");
|
||
assert_eq!(dw, window);
|
||
assert_eq!(dw.center, (10, -5));
|
||
assert_eq!(dw.n, 2);
|
||
assert_eq!(
|
||
dw.temp_dc[2], REGION_TEMP_NONE_DC,
|
||
"airless sentinel preserved"
|
||
);
|
||
assert_eq!(dw.vegetation[0], 6, "Marine discriminant preserved");
|
||
}
|
||
|
||
/// Wire back-compat (D-226 T-1124 amendment §1): a pre-T-1137 request
|
||
/// frame carrying only `{body_id, up_to}` — no `window_center`/`window_n`
|
||
/// keys at all — decodes cleanly via `#[serde(default)]`, byte-unchanged
|
||
/// for every existing caller.
|
||
#[test]
|
||
fn old_request_frame_without_window_fields_decodes_with_none() {
|
||
#[derive(serde::Serialize)]
|
||
struct OldAtlasLayerRequest {
|
||
body_id: String,
|
||
up_to: CascadeLayer,
|
||
}
|
||
let old = OldAtlasLayerRequest {
|
||
body_id: "GJ1c".into(),
|
||
up_to: CascadeLayer::Topography,
|
||
};
|
||
let bytes = rmp_serde::to_vec_named(&old).expect("encode old-shape frame");
|
||
let decoded: AtlasLayerRequest = rmp_serde::from_slice(&bytes).expect("decode");
|
||
assert_eq!(decoded.body_id, "GJ1c");
|
||
assert_eq!(decoded.up_to, CascadeLayer::Topography);
|
||
assert_eq!(decoded.window_center, None);
|
||
assert_eq!(decoded.window_n, 0);
|
||
assert_eq!(
|
||
decoded.window_granularity_v2, None,
|
||
"T-1152/T-1159: absent window_granularity_v2 decodes to None (district), byte-compatible"
|
||
);
|
||
assert_eq!(
|
||
decoded.window_min_wl_m, 0,
|
||
"T-1150: absent window_min_wl_m decodes to 0 (no cutoff), byte-compatible"
|
||
);
|
||
}
|
||
|
||
/// T-1119: `build_quarter_footprint_layer` returns `None` when no
|
||
/// settlement's quarter skeleton has been generated (`state.quarters`
|
||
/// empty), mirroring `build_district_grid`/`build_region_grid`'s
|
||
/// "unrun layer → None" contract.
|
||
#[test]
|
||
fn quarter_footprint_layer_none_when_quarters_empty() {
|
||
let state = blank_state("GJ1c");
|
||
assert!(build_quarter_footprint_layer(&state, 42).is_none());
|
||
}
|
||
|
||
/// A `BlockSkeleton` fixture builder for quarter-footprint tests — only
|
||
/// the fields the aggregate reads are wired; the rest default.
|
||
fn block(
|
||
zoning: crate::simulation::generator::ZoningType,
|
||
district_type: DistrictType,
|
||
density_pct: u8,
|
||
landmark: Option<crate::simulation::generator::LandmarkSlot>,
|
||
) -> crate::simulation::generator::BlockSkeleton {
|
||
crate::simulation::generator::BlockSkeleton {
|
||
zoning,
|
||
district_type,
|
||
density_pct,
|
||
landmark,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// T-1119: a populated quarter aggregates correctly — the density mean,
|
||
/// the dominant-mode fields (including a tie resolving to the lowest
|
||
/// declaration-order variant per the D-226 T-1112 amendment §1), the
|
||
/// landmark count, and the corridor count.
|
||
#[test]
|
||
fn quarter_footprint_layer_aggregates_populated_quarter() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
let placement = CityPlacement {
|
||
city_id: 7,
|
||
name: "Millbrook".into(),
|
||
position: (30, 40),
|
||
attractor_type: AttractorType::ValleyFloor,
|
||
score: 500,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population: 200_000,
|
||
is_capital: false,
|
||
is_standalone_hq: false,
|
||
};
|
||
state.placements = vec![placement.clone()];
|
||
|
||
let world_seed = 42;
|
||
let quarter_id = SeedChain::for_body(world_seed, "GJ1c")
|
||
.derive(SeedDomain::Layer4Quarter, placement.city_id)
|
||
.seed();
|
||
|
||
// 16 blocks: 10 Commercial/Commercial, 6 Industrial/Industrial — a
|
||
// clean (non-tied) mode on both district_type and zoning, plus a
|
||
// known density mean and landmark/corridor counts.
|
||
let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default();
|
||
let mut flat: Vec<&mut crate::simulation::generator::BlockSkeleton> =
|
||
blocks.iter_mut().flatten().collect();
|
||
for (i, b) in flat.iter_mut().enumerate() {
|
||
if i < 10 {
|
||
**b = block(ZoningType::Commercial, DistrictType::Commercial, 60, None);
|
||
} else {
|
||
**b = block(
|
||
ZoningType::Industrial,
|
||
DistrictType::Industrial,
|
||
20,
|
||
Some("landmark".to_string()),
|
||
);
|
||
}
|
||
}
|
||
// 3 landmarks among the Industrial blocks (indices 10, 11, 12).
|
||
*flat[10] = block(
|
||
ZoningType::Industrial,
|
||
DistrictType::Industrial,
|
||
20,
|
||
Some("A".to_string()),
|
||
);
|
||
*flat[11] = block(
|
||
ZoningType::Industrial,
|
||
DistrictType::Industrial,
|
||
20,
|
||
Some("B".to_string()),
|
||
);
|
||
*flat[12] = block(
|
||
ZoningType::Industrial,
|
||
DistrictType::Industrial,
|
||
20,
|
||
Some("C".to_string()),
|
||
);
|
||
for b in flat.iter_mut().skip(13) {
|
||
**b = block(ZoningType::Industrial, DistrictType::Industrial, 20, None);
|
||
}
|
||
|
||
// (10 * 60 + 6 * 20) / 16 = 720 / 16 = 45.
|
||
state.quarters.insert(
|
||
quarter_id,
|
||
crate::simulation::generator::QuarterWorldState {
|
||
skeleton: crate::simulation::generator::QuarterSkeleton {
|
||
quarter_id,
|
||
blocks,
|
||
corridors: vec![
|
||
crate::simulation::generator::CorridorSpine {
|
||
from: 0,
|
||
to: 1,
|
||
path: vec![(0, 0), (4, 4)],
|
||
},
|
||
crate::simulation::generator::CorridorSpine {
|
||
from: 1,
|
||
to: 2,
|
||
path: vec![(4, 4), (8, 8)],
|
||
},
|
||
],
|
||
..Default::default()
|
||
},
|
||
block_tags: Default::default(),
|
||
},
|
||
);
|
||
|
||
let layer =
|
||
build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some");
|
||
let entry = layer.entries.get(&7).expect("city_id 7 entry present");
|
||
assert_eq!(entry.city_id, 7);
|
||
assert_eq!(entry.density_avg_pct, 45);
|
||
assert_eq!(entry.dominant_district_type, DistrictType::Commercial);
|
||
assert_eq!(entry.dominant_zoning, ZoningType::Commercial);
|
||
assert_eq!(entry.landmark_count, 3);
|
||
assert_eq!(entry.corridor_count, 2);
|
||
}
|
||
|
||
/// T-1119: the mode tie-break resolves to the lowest declaration-order
|
||
/// variant (the `Ord` derive), per the D-226 T-1112 amendment §1's
|
||
/// explicit tie rule — this is the reason `ZoningType` gained
|
||
/// `PartialOrd`/`Ord` in this same ticket.
|
||
#[test]
|
||
fn quarter_footprint_layer_tie_breaks_by_declaration_order() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
let placement = CityPlacement {
|
||
city_id: 3,
|
||
name: "Farmstead Rell".into(),
|
||
position: (50, 60),
|
||
attractor_type: AttractorType::PlainCenter,
|
||
score: 100,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population: 8_000,
|
||
is_capital: false,
|
||
is_standalone_hq: false,
|
||
};
|
||
state.placements = vec![placement.clone()];
|
||
|
||
let world_seed = 99;
|
||
let quarter_id = SeedChain::for_body(world_seed, "GJ1c")
|
||
.derive(SeedDomain::Layer4Quarter, placement.city_id)
|
||
.seed();
|
||
|
||
// 8 blocks Industrial, 8 blocks Commercial — an exact tie. Declaration
|
||
// order on both DistrictType and ZoningType lists Commercial before
|
||
// Industrial, so the tie-broken dominant must be Commercial on both.
|
||
let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default();
|
||
for (i, b) in blocks.iter_mut().flatten().enumerate() {
|
||
*b = if i < 8 {
|
||
block(ZoningType::Industrial, DistrictType::Industrial, 50, None)
|
||
} else {
|
||
block(ZoningType::Commercial, DistrictType::Commercial, 50, None)
|
||
};
|
||
}
|
||
state.quarters.insert(
|
||
quarter_id,
|
||
crate::simulation::generator::QuarterWorldState {
|
||
skeleton: crate::simulation::generator::QuarterSkeleton {
|
||
quarter_id,
|
||
blocks,
|
||
..Default::default()
|
||
},
|
||
block_tags: Default::default(),
|
||
},
|
||
);
|
||
|
||
let layer =
|
||
build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some");
|
||
let entry = layer.entries.get(&3).expect("city_id 3 entry present");
|
||
assert_eq!(
|
||
entry.dominant_district_type,
|
||
DistrictType::Commercial,
|
||
"tie resolves to Commercial (declared before Industrial)"
|
||
);
|
||
assert_eq!(
|
||
entry.dominant_zoning,
|
||
ZoningType::Commercial,
|
||
"tie resolves to Commercial (declared before Industrial)"
|
||
);
|
||
}
|
||
|
||
/// T-1119: a placement whose deterministically-derived `quarter_id` is
|
||
/// NOT yet in `state.quarters` (skeleton generation is async, dispatched
|
||
/// after the body's own snapshot is cached — D-226 T-1112 amendment §1)
|
||
/// is skipped, not defaulted. `entries` is a subset of `placements`.
|
||
#[test]
|
||
fn quarter_footprint_layer_skips_placement_without_matching_quarter() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
let has_quarter = CityPlacement {
|
||
city_id: 1,
|
||
name: "Port Aldren".into(),
|
||
position: (12, 58),
|
||
attractor_type: AttractorType::CoastalAccess,
|
||
score: 1000,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population: 2_000_000,
|
||
is_capital: true,
|
||
is_standalone_hq: false,
|
||
};
|
||
let no_quarter_yet = CityPlacement {
|
||
city_id: 2,
|
||
name: "Farmstead Rell".into(),
|
||
..has_quarter.clone()
|
||
};
|
||
state.placements = vec![has_quarter.clone(), no_quarter_yet];
|
||
|
||
let world_seed = 42;
|
||
let quarter_id = SeedChain::for_body(world_seed, "GJ1c")
|
||
.derive(SeedDomain::Layer4Quarter, has_quarter.city_id)
|
||
.seed();
|
||
let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default();
|
||
for b in blocks.iter_mut().flatten() {
|
||
*b = block(ZoningType::Mixed, DistrictType::MixedUse, 10, None);
|
||
}
|
||
state.quarters.insert(
|
||
quarter_id,
|
||
crate::simulation::generator::QuarterWorldState {
|
||
skeleton: crate::simulation::generator::QuarterSkeleton {
|
||
quarter_id,
|
||
blocks,
|
||
..Default::default()
|
||
},
|
||
block_tags: Default::default(),
|
||
},
|
||
);
|
||
|
||
let layer =
|
||
build_quarter_footprint_layer(&state, world_seed).expect("populated quarters → Some");
|
||
assert_eq!(
|
||
layer.entries.len(),
|
||
1,
|
||
"only the placement with a matching quarter gets an entry"
|
||
);
|
||
assert!(layer.entries.contains_key(&1));
|
||
assert!(
|
||
!layer.entries.contains_key(&2),
|
||
"city_id 2 has no generated quarter yet — must be absent, not defaulted"
|
||
);
|
||
}
|
||
|
||
/// T-1119 (D-010): building the layer twice from identical state produces
|
||
/// byte-identical output — the `city_id → quarter_id` derivation and the
|
||
/// mode aggregation are pure functions of their inputs.
|
||
#[test]
|
||
fn quarter_footprint_layer_is_deterministic() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, FoundingOrientation, PoliticalArchetype, ZoningType,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
let placement = CityPlacement {
|
||
city_id: 5,
|
||
name: "Groombridge".into(),
|
||
position: (1, 1),
|
||
attractor_type: AttractorType::PlainCenter,
|
||
score: 300,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population: 60_000,
|
||
is_capital: false,
|
||
is_standalone_hq: false,
|
||
};
|
||
state.placements = vec![placement.clone()];
|
||
|
||
let world_seed = 7;
|
||
let quarter_id = SeedChain::for_body(world_seed, "GJ1c")
|
||
.derive(SeedDomain::Layer4Quarter, placement.city_id)
|
||
.seed();
|
||
let mut blocks: [[crate::simulation::generator::BlockSkeleton; 4]; 4] = Default::default();
|
||
for (i, b) in blocks.iter_mut().flatten().enumerate() {
|
||
*b = block(
|
||
ZoningType::Residential,
|
||
DistrictType::Residential,
|
||
(i as u8) * 5,
|
||
None,
|
||
);
|
||
}
|
||
state.quarters.insert(
|
||
quarter_id,
|
||
crate::simulation::generator::QuarterWorldState {
|
||
skeleton: crate::simulation::generator::QuarterSkeleton {
|
||
quarter_id,
|
||
blocks,
|
||
..Default::default()
|
||
},
|
||
block_tags: Default::default(),
|
||
},
|
||
);
|
||
|
||
let a = build_quarter_footprint_layer(&state, world_seed);
|
||
let b = build_quarter_footprint_layer(&state, world_seed);
|
||
assert_eq!(a, b, "identical state must produce identical output");
|
||
}
|
||
|
||
/// A blank `BodyWorldState` for tests that only care about one field —
|
||
/// callers overwrite `placements`/`road_graph`/etc. as needed.
|
||
fn blank_state(body_id: &str) -> BodyWorldState {
|
||
BodyWorldState {
|
||
body_id: body_id.into(),
|
||
heightmap: vec![],
|
||
heightmap_width: 16,
|
||
heightmap_height: 8,
|
||
sea_level: 0.3,
|
||
river_network: RiverNetwork::default(),
|
||
drainage_basins: vec![],
|
||
attractors: vec![],
|
||
feature_names: vec![],
|
||
placements: vec![],
|
||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||
quarters: std::collections::BTreeMap::new(),
|
||
districts: std::collections::BTreeMap::new(),
|
||
regions: std::collections::BTreeMap::new(),
|
||
last_accessed: 0,
|
||
}
|
||
}
|
||
|
||
/// T-960 §1: `build_road_graph_layer` trims the internal `RoadGraph` (drops
|
||
/// `degree`/`parent_edge`/`length_cells`) while keeping everything a
|
||
/// planetary-map overlay needs (positions, kind, polyline, maintenance,
|
||
/// rail flag, named-route id).
|
||
#[test]
|
||
fn road_graph_layer_built_from_cached_state() {
|
||
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
|
||
use crate::simulation::generator::MaintenanceAuthority;
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
state.road_graph = RoadGraph {
|
||
nodes: vec![
|
||
RoadNode {
|
||
city_id: Some(1),
|
||
position: (10, 20),
|
||
kind: RoadNodeKind::Settlement,
|
||
degree: 1,
|
||
parent_edge: None,
|
||
is_hub: false,
|
||
},
|
||
RoadNode {
|
||
city_id: None,
|
||
position: (15, 25),
|
||
kind: RoadNodeKind::Waypoint,
|
||
degree: 0,
|
||
parent_edge: Some(0),
|
||
is_hub: false,
|
||
},
|
||
],
|
||
edges: vec![RoadEdge {
|
||
from: 0,
|
||
to: 1,
|
||
path: vec![(10, 20), (15, 25)],
|
||
length_cells: 20, // internal routing-grid measure — dropped
|
||
maintenance: MaintenanceAuthority::Trade,
|
||
named_route_id: Some("split/hwy-1".into()),
|
||
is_rail: true,
|
||
}],
|
||
};
|
||
|
||
let layer = build_road_graph_layer(&state).expect("populated road_graph → Some");
|
||
assert_eq!(layer.nodes.len(), 2);
|
||
assert_eq!(layer.nodes[0].position, (10, 20));
|
||
assert_eq!(layer.nodes[0].kind, RoadNodeKind::Settlement);
|
||
assert_eq!(layer.nodes[0].city_id, Some(1));
|
||
assert_eq!(layer.nodes[1].kind, RoadNodeKind::Waypoint);
|
||
assert_eq!(layer.nodes[1].city_id, None);
|
||
assert_eq!(layer.edges.len(), 1);
|
||
assert_eq!(layer.edges[0].path, vec![(10, 20), (15, 25)]);
|
||
assert_eq!(layer.edges[0].maintenance, MaintenanceAuthority::Trade);
|
||
assert!(layer.edges[0].is_rail);
|
||
assert_eq!(
|
||
layer.edges[0].named_route_id.as_deref(),
|
||
Some("split/hwy-1")
|
||
);
|
||
|
||
// Layer hasn't run (or zero settlements) → None, mirroring district_grid.
|
||
let unrun = blank_state("GJ1c");
|
||
assert!(build_road_graph_layer(&unrun).is_none());
|
||
}
|
||
|
||
/// T-960 §2: `build_settlement_layer` derives `size_class` from population
|
||
/// using the D-211 Tier A/B cutoffs, threads `is_capital` straight through,
|
||
/// and derives `is_port` cheaply from the anchoring attractor type.
|
||
#[test]
|
||
fn settlement_layer_built_from_cached_placements() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, FoundingOrientation, PoliticalArchetype,
|
||
};
|
||
|
||
let mk = |city_id: u64,
|
||
name: &str,
|
||
pos: (u16, u16),
|
||
population: i64,
|
||
is_capital: bool,
|
||
attractor_type: AttractorType| CityPlacement {
|
||
city_id,
|
||
name: name.to_string(),
|
||
position: pos,
|
||
attractor_type,
|
||
score: 1000,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population,
|
||
is_capital,
|
||
is_standalone_hq: false,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
state.placements = vec![
|
||
mk(
|
||
1,
|
||
"Port Aldren",
|
||
(12, 58),
|
||
2_000_000,
|
||
true,
|
||
AttractorType::CoastalAccess,
|
||
),
|
||
mk(
|
||
2,
|
||
"Millbrook",
|
||
(30, 40),
|
||
200_000,
|
||
false,
|
||
AttractorType::ValleyFloor,
|
||
),
|
||
mk(
|
||
3,
|
||
"Farmstead Rell",
|
||
(50, 60),
|
||
8_000,
|
||
false,
|
||
AttractorType::PlainCenter,
|
||
),
|
||
];
|
||
|
||
let layer = build_settlement_layer(&state).expect("populated placements → Some");
|
||
assert_eq!(layer.settlements.len(), 3);
|
||
|
||
let capital = layer.settlements.iter().find(|s| s.city_id == 1).unwrap();
|
||
assert_eq!(capital.name, "Port Aldren");
|
||
assert_eq!(capital.position, (12, 58));
|
||
assert_eq!(capital.size_class, SettlementSizeClass::Major);
|
||
assert!(capital.is_capital);
|
||
assert!(capital.is_port, "CoastalAccess must read as a port");
|
||
|
||
let mid = layer.settlements.iter().find(|s| s.city_id == 2).unwrap();
|
||
assert_eq!(mid.size_class, SettlementSizeClass::Standard);
|
||
assert!(!mid.is_capital);
|
||
assert!(!mid.is_port, "ValleyFloor is not a port attractor");
|
||
|
||
let small = layer.settlements.iter().find(|s| s.city_id == 3).unwrap();
|
||
assert_eq!(small.size_class, SettlementSizeClass::Minor);
|
||
assert!(!small.is_port);
|
||
|
||
// No placements → None.
|
||
let unrun = blank_state("GJ1c");
|
||
assert!(build_settlement_layer(&unrun).is_none());
|
||
}
|
||
|
||
/// T-960 / T-1119: the new layers survive a MessagePack round trip inside
|
||
/// `AtlasLayerResponse` — the same wire path the bridge uses
|
||
/// (`rmp_serde::to_vec_named` / `from_slice`, matching `layer1`/
|
||
/// `district_grid`'s existing serialization).
|
||
#[test]
|
||
fn atlas_layer_response_with_new_layers_round_trips_msgpack() {
|
||
use crate::atlas::attractor_matching::CityPlacement;
|
||
use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode};
|
||
use crate::simulation::generator::{
|
||
ArrangementPattern, BlockSkeleton, DistrictType, FoundingOrientation,
|
||
MaintenanceAuthority, PoliticalArchetype, QuarterSkeleton, QuarterWorldState,
|
||
ZoningType,
|
||
};
|
||
|
||
let mut state = blank_state("GJ1c");
|
||
state.placements = vec![CityPlacement {
|
||
city_id: 1,
|
||
name: "Port Aldren".into(),
|
||
position: (12, 58),
|
||
attractor_type: AttractorType::CoastalAccess,
|
||
score: 1000,
|
||
synthetic: false,
|
||
political_archetype: PoliticalArchetype::Commission,
|
||
arrangement_pattern: ArrangementPattern::RadialCore,
|
||
founding_orientation: FoundingOrientation::Cardinal,
|
||
population: 2_000_000,
|
||
is_capital: true,
|
||
is_standalone_hq: false,
|
||
}];
|
||
state.road_graph = RoadGraph {
|
||
nodes: vec![RoadNode {
|
||
city_id: Some(1),
|
||
position: (12, 58),
|
||
kind: RoadNodeKind::Settlement,
|
||
degree: 0,
|
||
parent_edge: None,
|
||
is_hub: false,
|
||
}],
|
||
edges: vec![RoadEdge {
|
||
from: 0,
|
||
to: 0,
|
||
path: vec![(12, 58)],
|
||
length_cells: 0,
|
||
maintenance: MaintenanceAuthority::Administrative,
|
||
named_route_id: None,
|
||
is_rail: false,
|
||
}],
|
||
};
|
||
let world_seed = 42;
|
||
let quarter_id = SeedChain::for_body(world_seed, "GJ1c")
|
||
.derive(SeedDomain::Layer4Quarter, 1)
|
||
.seed();
|
||
let mut block = BlockSkeleton {
|
||
zoning: ZoningType::Commercial,
|
||
district_type: DistrictType::Commercial,
|
||
density_pct: 40,
|
||
..Default::default()
|
||
};
|
||
block.position = (0, 0);
|
||
state.quarters.insert(
|
||
quarter_id,
|
||
QuarterWorldState {
|
||
skeleton: QuarterSkeleton {
|
||
quarter_id,
|
||
blocks: std::array::from_fn(|_| std::array::from_fn(|_| block.clone())),
|
||
..Default::default()
|
||
},
|
||
block_tags: Default::default(),
|
||
},
|
||
);
|
||
|
||
let resp = AtlasLayerResponse {
|
||
body_id: "GJ1c".into(),
|
||
status: AtlasLayerStatus::Ready,
|
||
layer1: None,
|
||
district_grid: None,
|
||
road_graph: build_road_graph_layer(&state),
|
||
settlements: build_settlement_layer(&state),
|
||
region_grid: build_region_grid(&state),
|
||
district_window: None,
|
||
quarter_footprints: build_quarter_footprint_layer(&state, world_seed),
|
||
};
|
||
|
||
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
||
let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode");
|
||
|
||
assert_eq!(decoded.body_id, "GJ1c");
|
||
let rg = decoded.road_graph.expect("road_graph survives round trip");
|
||
assert_eq!(rg.nodes[0].position, (12, 58));
|
||
assert_eq!(
|
||
rg.edges[0].maintenance,
|
||
MaintenanceAuthority::Administrative
|
||
);
|
||
let qf = decoded
|
||
.quarter_footprints
|
||
.expect("quarter_footprints survives round trip");
|
||
let entry = qf.entries.get(&1).expect("city_id 1 entry present");
|
||
assert_eq!(entry.density_avg_pct, 40);
|
||
assert_eq!(entry.dominant_district_type, DistrictType::Commercial);
|
||
assert_eq!(entry.dominant_zoning, ZoningType::Commercial);
|
||
let settlements = decoded
|
||
.settlements
|
||
.expect("settlements survives round trip");
|
||
assert_eq!(settlements.settlements[0].name, "Port Aldren");
|
||
assert_eq!(
|
||
settlements.settlements[0].size_class,
|
||
SettlementSizeClass::Major
|
||
);
|
||
assert!(settlements.settlements[0].is_capital);
|
||
assert!(settlements.settlements[0].is_port);
|
||
}
|
||
|
||
fn req(body_id: &str) -> AtlasLayerRequest {
|
||
AtlasLayerRequest {
|
||
body_id: body_id.to_string(),
|
||
up_to: CascadeLayer::Topography,
|
||
window_center: None,
|
||
window_n: 0,
|
||
window_granularity_v2: None,
|
||
window_min_wl_m: 0,
|
||
}
|
||
}
|
||
|
||
fn test_conn_id() -> ConnectionId {
|
||
ConnectionId(1)
|
||
}
|
||
|
||
const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
|
||
|
||
/// systems.db with one bodies row, + a base root containing a tiny 16-bit
|
||
/// heightmap PNG at the body's terrain_reference. Returns (db, resolver).
|
||
fn resolver_with_body(body_id: &str) -> (PathBuf, BodySourceResolver) {
|
||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let db = std::env::temp_dir().join(format!("sr_proxy_{}_{n}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&db);
|
||
let conn = Connection::open(&db).unwrap();
|
||
conn.execute(
|
||
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, terrain_reference TEXT)",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
conn.execute(
|
||
"INSERT INTO bodies (body_id, terrain_reference) VALUES (?1, ?2)",
|
||
rusqlite::params![body_id, REL],
|
||
)
|
||
.unwrap();
|
||
|
||
let root = std::env::temp_dir().join(format!("sr_proxyroot_{}_{n}", std::process::id()));
|
||
write_tiny_heightmap(&root.join(REL));
|
||
|
||
let resolver = BodySourceResolver::open(&db, vec![root]).unwrap();
|
||
(db, resolver)
|
||
}
|
||
|
||
fn write_tiny_heightmap(path: &Path) {
|
||
use std::io::BufWriter;
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
let file = std::fs::File::create(path).unwrap();
|
||
let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16);
|
||
enc.set_color(png::ColorType::Grayscale);
|
||
enc.set_depth(png::BitDepth::Sixteen);
|
||
let mut w = enc.write_header().unwrap();
|
||
let data: Vec<u8> = (0..32u32 * 16)
|
||
.flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes())
|
||
.collect();
|
||
w.write_image_data(&data).unwrap();
|
||
}
|
||
|
||
fn empty_resolver() -> (PathBuf, BodySourceResolver) {
|
||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let db = std::env::temp_dir().join(format!("sr_proxye_{}_{n}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&db);
|
||
let conn = Connection::open(&db).unwrap();
|
||
conn.execute(
|
||
"CREATE TABLE bodies (body_id TEXT, terrain_reference TEXT)",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
let resolver = BodySourceResolver::open(&db, vec![std::env::temp_dir()]).unwrap();
|
||
(db, resolver)
|
||
}
|
||
|
||
#[test]
|
||
fn cache_hit_is_ready() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
cache.insert(BodyWorldState {
|
||
body_id: "GJ1c".into(),
|
||
heightmap: vec![0.0; 4],
|
||
heightmap_width: 2,
|
||
heightmap_height: 2,
|
||
sea_level: 0.3,
|
||
river_network: RiverNetwork::default(),
|
||
drainage_basins: vec![],
|
||
attractors: vec![],
|
||
feature_names: vec![],
|
||
placements: vec![],
|
||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||
quarters: std::collections::BTreeMap::new(),
|
||
districts: std::collections::BTreeMap::new(),
|
||
regions: std::collections::BTreeMap::new(),
|
||
last_accessed: 0,
|
||
});
|
||
let (_db, resolver) = empty_resolver();
|
||
let queue = GenerationQueue::with_threads(1);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("GJ1c"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
None,
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(resp.status, AtlasLayerStatus::Ready);
|
||
assert_eq!(resp.layer1.expect("layer1").body_id, "GJ1c");
|
||
}
|
||
|
||
#[test]
|
||
fn cache_miss_enqueues_and_pends_then_analyzes() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let (_db, resolver) = resolver_with_body("GJ1c");
|
||
let queue = GenerationQueue::with_threads(1);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("GJ1c"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
None,
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||
assert!(resp.layer1.is_none());
|
||
|
||
// The enqueued analysis runs the real cascade and completes.
|
||
std::thread::sleep(Duration::from_millis(150));
|
||
let completions = queue.drain_completions();
|
||
assert!(
|
||
completions.iter().any(
|
||
|c| matches!(c, GenCompletion::BodyAnalyzed { body_id, .. } if body_id == "GJ1c")
|
||
),
|
||
"miss should enqueue an AnalyzeBody that completes: {completions:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_body_is_not_found() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let (_db, resolver) = empty_resolver();
|
||
let queue = GenerationQueue::with_threads(1);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("ghost"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
None,
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(resp.status, AtlasLayerStatus::NotFound);
|
||
}
|
||
|
||
/// Build a DB with the columns needed by both `BodySourceResolver` and
|
||
/// `BodyParamsReader` for the same body (Earth radius, 6371 km), plus a
|
||
/// tiny heightmap root.
|
||
///
|
||
/// Returns (db_path, resolver, body_params_reader, _root_kept_alive).
|
||
fn resolver_and_params_reader(
|
||
body_id: &str,
|
||
) -> (
|
||
PathBuf,
|
||
BodySourceResolver,
|
||
crate::atlas::body_params_reader::BodyParamsReader,
|
||
PathBuf, // root dir — must stay alive for the test duration
|
||
) {
|
||
resolver_and_params_reader_with_radius(body_id, 6371.0)
|
||
}
|
||
|
||
/// Same as [`resolver_and_params_reader`] with a caller-chosen
|
||
/// `body_radius_km` (T-1142: the window-centre normalization tests need a
|
||
/// SMALL body — at Earth radius the district-circumference/half-meridian
|
||
/// bounds are tens of thousands of districts wide, too large for a
|
||
/// hand-checkable out-of-range test value).
|
||
fn resolver_and_params_reader_with_radius(
|
||
body_id: &str,
|
||
r_km: f64,
|
||
) -> (
|
||
PathBuf,
|
||
BodySourceResolver,
|
||
crate::atlas::body_params_reader::BodyParamsReader,
|
||
PathBuf, // root dir — must stay alive for the test duration
|
||
) {
|
||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let db = std::env::temp_dir().join(format!("sr_proxybp_{}_{n}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&db);
|
||
let conn = Connection::open(&db).unwrap();
|
||
|
||
conn.execute_batch(
|
||
"CREATE TABLE star_systems (
|
||
system_id TEXT PRIMARY KEY,
|
||
spectral_class TEXT,
|
||
star_type TEXT
|
||
);
|
||
CREATE TABLE bodies (
|
||
body_id TEXT PRIMARY KEY,
|
||
system_id TEXT,
|
||
terrain_reference TEXT,
|
||
hydrosphere TEXT,
|
||
atmosphere TEXT,
|
||
planet_class TEXT,
|
||
body_radius_km REAL,
|
||
orbital_period_days REAL,
|
||
axial_tilt_deg REAL
|
||
);",
|
||
)
|
||
.unwrap();
|
||
|
||
conn.execute(
|
||
"INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES ('GJ-1', 'G', 'main_sequence')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
conn.execute(
|
||
"INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, body_radius_km, orbital_period_days, axial_tilt_deg)
|
||
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', ?3, 365.25, 23.5)",
|
||
rusqlite::params![body_id, REL, r_km],
|
||
)
|
||
.unwrap();
|
||
drop(conn);
|
||
|
||
let root = std::env::temp_dir().join(format!("sr_proxybproot_{}_{n}", std::process::id()));
|
||
write_tiny_heightmap(&root.join(REL));
|
||
|
||
let resolver = BodySourceResolver::open(&db, vec![root.clone()]).unwrap();
|
||
let params_reader = crate::atlas::body_params_reader::BodyParamsReader::open(&db).unwrap();
|
||
|
||
(db, resolver, params_reader, root)
|
||
}
|
||
|
||
/// With body_params_reader wired, a cache miss enqueues an AnalyzeBody that
|
||
/// completes with populated `districts` (DistrictProfile layer ran) AND
|
||
/// populated `regions` (Region layer ran — the production terminal,
|
||
/// T-1113). Then the completed state served back through
|
||
/// `handle_atlas_request` carries a `region_grid` — closing the full
|
||
/// dispatch → Ready → region_grid loop (PR #179 F4).
|
||
#[test]
|
||
fn body_params_reader_wired_produces_populated_regions() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||
let queue = GenerationQueue::with_threads(1);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("GJ1c"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||
|
||
// Wait for the Rayon work item to complete.
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let body_state = completions
|
||
.into_iter()
|
||
.find_map(|c| {
|
||
if let GenCompletion::BodyAnalyzed { body_id, state } = c {
|
||
if body_id == "GJ1c" {
|
||
return Some(state);
|
||
}
|
||
}
|
||
None
|
||
})
|
||
.expect("AnalyzeBody must complete for GJ1c");
|
||
|
||
assert!(
|
||
!body_state.districts.is_empty(),
|
||
"districts must be populated when body_params_reader is wired (T-1032 dispatch path)"
|
||
);
|
||
assert!(
|
||
!body_state.regions.is_empty(),
|
||
"regions must be populated when body_params_reader is wired (T-1113 dispatch path)"
|
||
);
|
||
|
||
// Serve the completed state back through the proxy: the cache-hit
|
||
// branch must build and include the region grid.
|
||
cache.insert(*body_state);
|
||
let ready = handle_atlas_request(
|
||
&req("GJ1c"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
Some(¶ms_reader),
|
||
42,
|
||
2,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(ready.status, AtlasLayerStatus::Ready);
|
||
assert!(
|
||
ready.region_grid.is_some(),
|
||
"a Ready response for a Region-populated body must carry region_grid"
|
||
);
|
||
}
|
||
|
||
/// Without body_params_reader (None), districts is empty — pre-T-1032 behaviour.
|
||
#[test]
|
||
fn no_body_params_reader_leaves_regions_empty() {
|
||
let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY);
|
||
let (_db, resolver) = resolver_with_body("GJ1c");
|
||
let queue = GenerationQueue::with_threads(1);
|
||
let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY);
|
||
|
||
let resp = handle_atlas_request(
|
||
&req("GJ1c"),
|
||
&mut cache,
|
||
&mut window_cache,
|
||
&queue,
|
||
&resolver,
|
||
None,
|
||
None, // no body_params_reader
|
||
42,
|
||
1,
|
||
test_conn_id(),
|
||
);
|
||
assert_eq!(resp.status, AtlasLayerStatus::Pending);
|
||
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
let completions = queue.drain_completions();
|
||
let body_state = completions
|
||
.into_iter()
|
||
.find_map(|c| {
|
||
if let GenCompletion::BodyAnalyzed { body_id, state } = c {
|
||
if body_id == "GJ1c" {
|
||
return Some(state);
|
||
}
|
||
}
|
||
None
|
||
})
|
||
.expect("AnalyzeBody must complete for GJ1c");
|
||
|
||
assert!(
|
||
body_state.districts.is_empty(),
|
||
"districts must remain empty when no body_params_reader is wired"
|
||
);
|
||
assert!(
|
||
body_state.regions.is_empty(),
|
||
"regions must remain empty when no body_params_reader is wired \
|
||
(the Region layer gates on body_params, T-1113)"
|
||
);
|
||
}
|
||
}
|