Merge remote-tracking branch 'origin/atlas-layer-wiring'

This commit is contained in:
2026-07-16 19:24:48 +02:00
13 changed files with 453 additions and 58 deletions
+7 -5
View File
@@ -774,11 +774,12 @@ static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
## graph and L3 settlement placements, mirroring the district_grid precedent
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
## Key names "road_graph"/"settlements" are the CONFIRMED wire contract —
## identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's field
## names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and the
## server's msgpack round-trip tests). This remains the one client-side spot
## to touch if the contract ever changes.
## region_grid (T-1113): the region climate grid, same passthrough pattern.
## Key names "road_graph"/"settlements"/"region_grid" are the CONFIRMED wire
## contract — identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's
## field names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and
## the server's msgpack round-trip tests). This remains the one client-side
## spot to touch if the contract ever changes.
static func atlas_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("status"):
return null
@@ -798,6 +799,7 @@ static func atlas_response_from_raw(raw: Variant) -> Variant:
"district_grid": raw.get("district_grid"),
"road_graph": raw.get("road_graph"),
"settlements": raw.get("settlements"),
"region_grid": raw.get("region_grid"),
}
@@ -1 +1 @@
§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ
§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ
@@ -1 +1 @@
body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements
𣇪body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements屨region_grid
Binary file not shown.
+41 -34
View File
@@ -26,17 +26,13 @@ static func _random_test_port() -> int:
func _spawn_server(server_path: String) -> bool:
for attempt in range(MAX_PORT_ATTEMPTS):
_test_port = _random_test_port()
var addr := "127.0.0.1:%d" % _test_port
_server_pid = OS.create_process(server_path, [addr])
if _server_pid <= 0:
continue
await get_tree().create_timer(0.15).timeout
if OS.is_process_running(_server_pid):
return true
_test_port = _random_test_port()
var addr := "127.0.0.1:%d" % _test_port
_server_pid = OS.create_process(server_path, [addr])
if _server_pid <= 0:
_server_pid = -1
return false
return false
return true
func after_test() -> void:
@@ -51,37 +47,48 @@ func after_test() -> void:
## Spawn the server, connect, and complete the protocol handshake.
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
## binary exists, so any failure here is a real failure (asserted loudly).
## The server binds its port AFTER plugin/simulation init (~250ms), so a
## random-port collision surfaces as a mid-connect process death, not an
## instant spawn failure — a death during the connect window therefore
## retries on a fresh port instead of failing the test (the push gate hit
## exactly that race: "Failed to bind ... Address already in use").
func _connect_to_server() -> bool:
var server_path := _server_binary_path()
var spawned := await _spawn_server(server_path)
assert_bool(spawned).override_failure_message(
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
).is_true()
if not spawned:
return false
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _server_pid > 0 and not OS.is_process_running(_server_pid):
push_warning("Server process died during connection")
for _attempt in range(MAX_PORT_ATTEMPTS):
if not _spawn_server(server_path):
continue
_bridge = LocalBridge.new()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if not OS.is_process_running(_server_pid):
push_warning(
"Server died pre-connect (port %d likely in use) — retrying" % _test_port
)
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
if connected:
break
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
_bridge.disconnect_from_server()
_bridge = null
if _server_pid > 0 and OS.is_process_running(_server_pid):
OS.kill(_server_pid)
_server_pid = -1
assert_bool(connected).override_failure_message(
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
"TCP connect to spawned server failed within %.1fs across %d port attempts"
% [CONNECT_TIMEOUT, MAX_PORT_ATTEMPTS]
).is_true()
if not connected:
return false
+38 -1
View File
@@ -1560,11 +1560,48 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **(c) Legend panel stands.** The left-side legend (shape/color key for attractor types and sub-biomes) remains an unshipped, in-scope deliverable of item (3)/T-960.
- **(d) No tile-level Atlas map.** The planetary Atlas maps generation layers down to settlement/quarter-skeleton granularity only; chunk/tile/voxel fill (L5) is verified by the believability/derivation harnesses and inspected **in-world in Phase 5** — never as a planetary map layer (at most aggregate stats). Ratifies T-1046's implementation precedent; matching D-191 amendment + CLAUDE.md Phase-4 wording updated the same day.
**Amended 2026-07-16 (T-1112 — coarse quarter-footprint Atlas layer, the L4 skeleton on the planetary map):** the fourth generation-cascade layer to land on the Atlas, sitting between T-1046's `district_grid` (D-239 coarse morphology) and item (d)'s hard ceiling. Design only — implementation is a follow-up ticket (touch points named in §4 below, none built here).
- **(1) `QuarterFootprintLayer` data shape.** Source of truth is `BodyWorldState.quarters: BTreeMap<QuarterId, QuarterWorldState>` (`QuarterWorldState { skeleton: QuarterSkeleton, block_tags }`), where `QuarterSkeleton.blocks: [[BlockSkeleton; 4]; 4]` carries `zoning: ZoningType`, `district_type: DistrictType`, `density_pct: u8`, `landmark: Option<LandmarkSlot>` per block, plus `corridors: Vec<CorridorSpine>` at the quarter level. Critically, **`QuarterWorldState` carries no independent spatial position** — `QuarterId` is a content-addressable hash (`SeedChain::for_body(world_seed, body_id).derive(SeedDomain::Layer4Quarter, city_id).seed()`, `plugin.rs::build_skeleton_work_item`), not a coordinate. The only spatial anchor a quarter has is the `city_id` it was generated for — which `SettlementLayer`/`CityPlacement` (T-960 §2, D-211) already carries as `(city_id, position)`. So the layer is **keyed by `city_id`, joined to a quarter by recomputing the same deterministic `quarter_id` derivation** the dispatch path already uses (a pure function of already-public inputs — no new field needed anywhere) and looking it up in `state.quarters`. This also means **no city-outline geometry is derived or served** — at planetary-map projection a 512 m quarter is roughly 1/80th of a heightmap pixel (a district ≈ 2048 m is already sub-pixel at ~4078 km/px, D-243), so there is no real silhouette to trace; the layer is aggregate stats anchored at the existing L3 settlement position, mirroring `RoadGraphLayer`/`SettlementLayer`'s established "trim the internal struct to what an overlay needs" pattern rather than inventing outline geometry with no data behind it.
Per settlement-with-quarters, the served aggregate is:
```rust
pub struct QuarterFootprintEntry {
pub city_id: u64,
pub density_avg_pct: u8, // basis-point mean of BlockSkeleton.density_pct, 16 blocks
pub dominant_district_type: DistrictType, // mode across 16 blocks; ties → lowest declaration-order variant
pub dominant_zoning: ZoningType, // mode across 16 blocks; same tie rule
pub landmark_count: u8, // count of Some(LandmarkSlot) across 16 blocks (max 16)
pub corridor_count: u8, // QuarterSkeleton.corridors.len(), clamped to u8
}
pub struct QuarterFootprintLayer {
pub entries: BTreeMap<u64, QuarterFootprintEntry>, // keyed by city_id, D-010 determinism
}
```
Five fields earn their place: `density_avg_pct` and the two dominant-mode fields are Araminta's color/shape encoding inputs (§3); `landmark_count`/`corridor_count` are inspection-only (the D-226(d) ceiling forbids them as a map-visible channel — they surface in the existing city-click sidebar instead, an `ImplantDataRow` addition, not a new draw call). Rejected from the set: per-block detail (violates the ceiling outright), `reservations`/`social_sites` counts (no consumer identified — Araminta's encoding doesn't need them and nothing else asked), and a float density (D-010 integer discipline — `density_pct` is already `u8` basis-point-flavored on the source struct, so the mean stays `u8`, no `f32` anywhere on the wire). `dominant_district_type`/`dominant_zoning` serialize as their named enum variant (serde default), following the `RoadGraphLayer`/`SettlementLayer` precedent (`RoadNodeKind`, `MaintenanceAuthority` — neither `repr(u8)`-pinned, serialized as names) rather than `district_grid`'s `as u8` byte-packing, which was specific to a dense `cols×rows` array where `MorphologyZone` is deliberately `repr(u8)`-pinned for that purpose; a handful of per-settlement aggregate fields have no such packing need. `BTreeMap<u64, _>` throughout for D-010 determinism, matching `block_tags`' own `BTreeMap<(u8,u8), _>` precedent on the source struct.
- **(2) Hard ceiling (binding).** This layer is the concrete instance of item (d)'s "at most aggregate stats" clause: five scalar fields per settlement, quantized `u8`, no per-block zoning/street/tag detail ever reaches the wire, and no chunk/tile/voxel data is touched (this layer reads only `QuarterSkeleton`/`BlockSkeleton`, never `FillChunk` output — a different generation phase entirely, D-230). If a future ticket wants finer planetary-scale detail than this, the answer is "no" per item (d), not "extend this struct" — the settlement/quarter-skeleton granularity ceiling applies to this layer by construction, not by restraint that could erode.
- **(3) Overlay encoding (Araminta).** New overlay id `gen_l4_quarters` (label `QTR`, `group: "toggle"`, matching the `gen_*` convention in `OVERLAY_DEFS`). **No outline is drawn** (per §1) — the layer is a **density-scaled glyph anchored at the existing L3 settlement position**, drawn in the same pass immediately after `_draw_gen_settlements` so it reads as "on top of" the city dot it annotates. Square side scales off `density_avg_pct` (e.g. `4.0 + density_avg * 6.0` px at 1.0 zoom, clamped `[4.0, 12.0]`) — dense build reads as a bigger block, sparse as smaller, without pretending to show real shape. **Shape carries identity, color carries intensity** (the same convention as `_sub_biome_color`/`MORPHOLOGY_COLORS`): shape = `dominant_district_type`, a small corner-notch glyph family on the filled square (plain = mixed/no clear dominant, corner tab top-right = commercial, corner tab bottom-right = industrial, small diamond cutout center = civic/landmark — capped at 34 variants, a coarse skeleton read, not a legend of every `DistrictType`); color = `density_avg_pct` on a single-hue intensity ramp within the existing settlement-gold family (`COLOR_SETTLEMENT``COLOR_SETTLEMENT_CAPITAL`-adjacent bright gold at high density), so the new layer reads as *part of* the settlement-marker family rather than a competing hue, since it always co-renders beside `gen_l3_settlements`. `landmark_count`/`corridor_count` are **not** a visual channel (§2's ceiling) — they surface as `ImplantDataRow`s in the existing city-click sidebar panel. Zoom gating reuses `SETTLEMENT_LABEL_MIN_ZOOM = 2.0` (no new threshold): below it, glyph draws at minimum size with color only (the notch is illegible at a few px anyway); at/above it, full size with the dominant-type notch visible. Legend entry (`GENERATION_LEGEND`):
```gdscript
{
"overlay_id": "gen_l4_quarters",
"title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
"rows": [
{"glyph": "▪", "color": Color(0.55, 0.48, 0.30, 0.6), "label": "low density"},
{"glyph": "▪", "color": Color(0.94, 0.82, 0.38, 1.0), "label": "high density"},
{"glyph": "◪", "color": Color.TRANSPARENT, "label": "dominant type (corner tab)"},
],
}
```
- **(4) Six wiring touch points (follow-up ticket, not built here).** `AtlasLayerResponse` gains `quarter_footprints: Option<QuarterFootprintLayer>` + a `build_quarter_footprint_layer(state, placements)` function mirroring `build_district_grid`'s "empty source → `None`" contract (`server/src/atlas/layer_proxy.rs`); `ZoningType` gains `PartialOrd, Ord` derives mirroring `DistrictType`'s T-994 precedent (`server/src/simulation/generator.rs` — declaration order is not a stability-pinned wire format on either enum, so the additive derive is safe), which is what makes §1's lowest-declaration-order tie-break computable for `dominant_zoning` (PR #179 review: `DistrictType` already carries the derives, `ZoningType` does not — the tie rule itself is unchanged); `protocol.gd` passthrough for the new field (mirrors the existing `district_grid`/`road_graph`/`settlements` fields); a `gen_l4_quarters` entry in `OVERLAY_DEFS` (`client/ui/implant/apps/atlas/atlas_viewer.gd`); a `_draw_gen_l4_quarters()` function imitating `_draw_gen_district`'s "read `viewer.get_generation_quarter_footprints()`, guard on `Dictionary`, draw" shape (`atlas_marker_overlay.gd`); and the `GENERATION_LEGEND` entry above (`atlas_legend_panel.gd`). `layer_proxy.rs` was mid-concurrent-edit for T-1113's `region_grid` addition at design time — read-only pass, no conflict expected (both land as new sibling `Option` fields on `AtlasLayerResponse`, following the same one-field-per-layer pattern the growth-ceiling note on that struct already anticipates naming T-1112 and T-1113 as the last two candidates).
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
- **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.
- **Raised by:** Jeroen + Claude (design), with Tyre (channel/pause/headless architecture) + Araminta (overlay encoding + affordance UX), 2026-05-24.
- **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer)
- **Cross-reference:** [D-225](#d-225) (layer-stream proxy — the data path), [D-166](#d-166) (per-layer Atlas progress viewer), [D-191](#d-191) (Atlas viewer), [D-169](#d-169) / [D-170](#d-170) (implant components / HUD occlusion — `gameplay_occluded` trigger), [D-200](#d-200) / [D-203](#d-203) (execution tiers / LRU cache), Q-099 (mod content catalog), `tests/run-visual` (capture primitive), `save_state.rs` (save-inspection consumer). **T-1112 amendment additionally:** [D-222](#d-222) (Quarter terminology — the 512m unit this layer surfaces), [D-234](#d-234) (footprint geometry — the block-subdivision source the aggregates summarize), [D-243](#d-243) (quarter = 512m rung, and the containment ladder that makes a quarter sub-pixel at planetary projection — Araminta's no-outline rationale), [D-010](#d-010) (determinism — integer-only aggregates, `BTreeMap` keying).
- **Dissent:** None
---
+9 -2
View File
@@ -497,7 +497,14 @@ pub fn seed_to_u64(seed: &str) -> u64 {
}
/// Resolve the committed inputs for `body_id` and run the real deterministic cascade
/// (through the road graph), returning the per-body world state to [`analyze`].
/// (through the full production depth, `CascadeLayer::Region`), returning the
/// per-body world state to [`analyze`].
///
/// The terminal layer here MIRRORS production (`gen_queue.rs::run_work_item`) by
/// ruling (PR #179 F3): a shallower harness run would silently hand future
/// believability conditions (D-245's climate-appropriateness is the natural
/// consumer of `state.regions`) an empty layer that production populates.
/// When production's terminal advances, advance this one with it.
///
/// `Err` if `systems.db` or the body's `heightmap.png` cannot be found, or the body
/// has no params — callers (the regression harness) may *skip* on that rather than
@@ -529,7 +536,7 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
&cities,
None,
Some(&params),
CascadeLayer::RoadGraph,
CascadeLayer::Region,
);
Ok(snapshot.into_body_world_state())
}
+12
View File
@@ -15,7 +15,9 @@ use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
use crate::atlas::region_profile::RegionProfile;
use crate::atlas::road_graph::RoadGraph;
use crate::atlas::scale::RegionPos;
use crate::simulation::generator::{
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
};
@@ -100,6 +102,15 @@ pub struct BodyWorldState {
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
/// Empty until the DistrictProfile layer has run.
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
/// Per-region (~205 km) climate context — season/weather/temperature
/// baseline cells (D-243 §3, T-1113).
///
/// Populated by the background cascade's Region layer; the covering grid
/// only (no blend-padding ring — see `cascade::LayerRegionOutput`).
/// `BTreeMap` keyed by `RegionPos` for D-010 determinism. Empty until the
/// Region layer has run. Footprint is trivial (~195×98 cells at the D-243
/// true-scale ceiling; a handful on today's working grids).
pub regions: BTreeMap<RegionPos, RegionProfile>,
/// Last sim tick this entry was read. Used for LRU eviction.
pub last_accessed: SimTick,
}
@@ -224,6 +235,7 @@ mod tests {
road_graph: RoadGraph::default(),
quarters: BTreeMap::new(),
districts: BTreeMap::new(),
regions: BTreeMap::new(),
last_accessed: tick,
}
}
+147 -1
View File
@@ -28,8 +28,9 @@ use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProf
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::road_graph::{self, RoadGraph};
use crate::atlas::scale;
use crate::atlas::scale::{self, RegionPos};
use crate::seed::SeedChain;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
@@ -57,6 +58,16 @@ pub enum CascadeLayer {
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
RoadGraph,
/// Region climate layer (~205 km cells, D-243 §3, T-1113). Pure function of
/// `(seed, body_params, heightmap dims)` — the region baselines the district
/// layer already derives internally (and discards) are RETAINED here as
/// their own layer output for the Atlas. Semantically the climate context
/// *above* districts, but **appended last** per the append-only `Ord` rule
/// (the RoadGraph precedent): it depends on no other layer, so requesting
/// it runs the earlier layers first, harmlessly. The cheap double-derive
/// (district blend cache + this layer) is deliberate — one layer, one
/// concern, no cache plumbing between layers.
Region,
}
/// Output of the cascade for one body, up to the requested layer (#952).
@@ -81,6 +92,9 @@ pub struct CascadeSnapshot {
/// Layer 2 — inter-settlement road/rail graph. `Some` once
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
pub road_graph: Option<RoadGraph>,
/// Region climate layer — ~205 km climate-context cells. `Some` once
/// [`CascadeLayer::Region`] has run (D-243 §3, T-1113).
pub layer_region: Option<LayerRegionOutput>,
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
/// both `DistrictProfile` and `RoadGraph` have consumed it.
@@ -98,6 +112,19 @@ pub struct LayerDistrictOutput {
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
}
/// Region climate layer output (T-1113, D-243 §3): per-region (~205 km) climate
/// context covering the body's district grid. Stored in `BodyWorldState.regions`.
///
/// The set is the **covering grid only** — the regions whose districts tile the
/// body, with no ±1 neighbour padding. (The district layer's internal region
/// cache pads a neighbour ring because its edge-fuzz blend samples across
/// boundaries; that padding is a blend implementation detail, not part of the
/// body's own region grid, and a dense Atlas wire encoding wants exact dims.)
#[derive(Debug, Clone, Default)]
pub struct LayerRegionOutput {
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
}
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
#[derive(Debug, Clone, Default)]
@@ -123,6 +150,7 @@ impl CascadeSnapshot {
.layer_district
.map(|lr| lr.districts)
.unwrap_or_default();
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
let road_graph = self.road_graph.unwrap_or_default();
// terrain_analysis (transient) is intentionally dropped here.
let _ = self.terrain_analysis;
@@ -138,6 +166,7 @@ impl CascadeSnapshot {
road_graph,
quarters: std::collections::BTreeMap::new(),
districts,
regions,
last_accessed: 0,
}
}
@@ -203,6 +232,7 @@ pub fn run_cascade_from_heightmap(
layer3: None,
layer_district: None,
road_graph: None,
layer_region: None,
terrain_analysis: None,
};
@@ -330,6 +360,42 @@ pub fn run_cascade_from_heightmap(
snapshot.terrain_analysis = None;
}
// Region climate layer (D-243 §3, T-1113) — the ~205 km climate-context
// cells the district blend already derives internally, retained as their
// own layer output. Pure function of (seed, body_params, heightmap dims):
// no TerrainAnalysis needed, so it runs outside the transient-borrow block
// above. Gates on body_params like the DistrictProfile layer (no params →
// no climate inputs → the layer skips, `regions` stays empty).
if up_to >= CascadeLayer::Region {
if let Some(params) = body_params {
// The covering region grid: the same district dims the district
// layer computes (heightmap dims ÷ cells-per-district), mapped up
// to region cells — WITHOUT the ±1 neighbour padding the district
// blend cache adds (see LayerRegionOutput's doc).
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT.max(1);
let district_cols = (snapshot.heightmap.width as usize).div_ceil(gcpr) as i32;
let district_rows = (snapshot.heightmap.height as usize).div_ceil(gcpr) as i32;
let max_region = scale::district_to_region((
district_cols.saturating_sub(1),
district_rows.saturating_sub(1),
));
let mut region_positions: Vec<RegionPos> = Vec::new();
for ry in 0..=max_region.1 {
for rx in 0..=max_region.0 {
region_positions.push((rx, ry));
}
}
let climate = district_profile::ClimateConstants::default();
let regions = region_profile::derive_regions_for_body(
body_seed,
params,
&climate,
region_positions,
);
snapshot.layer_region = Some(LayerRegionOutput { regions });
}
}
snapshot
}
@@ -461,6 +527,7 @@ mod tests {
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
assert!(CascadeLayer::RoadGraph < CascadeLayer::Region);
}
#[test]
@@ -794,4 +861,83 @@ mod tests {
// Silence unused-import warning when the filter above changes.
let _ = RoadNodeKind::Settlement;
}
/// Region climate layer (T-1113, D-243 §3): runs as the cascade terminal,
/// populates `BodyWorldState.regions` with the covering region grid, gates
/// on body_params like the DistrictProfile layer, and is deterministic.
#[test]
fn region_layer_populates_regions_deterministically() {
use crate::atlas::district_profile::BodyParams;
let params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
..Default::default()
};
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
Some(&params),
CascadeLayer::Region,
)
};
let snap = run();
let lr = snap.layer_region.as_ref().expect("Region layer ran");
// 64×32 working grid → 8×4 districts → a single covering region at
// (0,0) (100 districts per region side — the working grid is far
// inside one region cell today; the D-243 elastic seam grows this).
assert_eq!(lr.regions.len(), 1, "one covering region on the test grid");
let profile = lr.regions.get(&(0, 0)).expect("region (0,0) present");
assert!(
profile.clock.mean_temp_c.is_some(),
"breathable temperate body derives a temperature baseline"
);
assert!((0..=100).contains(&profile.moisture_q));
// Determinism: identical inputs → bit-identical region output.
let key = |s: &CascadeSnapshot| {
s.layer_region
.as_ref()
.unwrap()
.regions
.iter()
.map(|(pos, p)| {
(
*pos,
p.clock.season as u8,
p.clock.weather as u8,
p.clock.mean_temp_c.map(f32::to_bits),
p.moisture_q,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
key(&snap),
key(&run()),
"region layer must be deterministic"
);
// The regions propagate into the hot-cache BodyWorldState.
let state = snap.into_body_world_state();
assert_eq!(state.regions.len(), 1);
assert!(state.regions.contains_key(&(0, 0)));
// No body params → the layer skips and regions stays empty (mirrors
// the DistrictProfile gate).
let no_params = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
CascadeLayer::Region,
);
assert!(no_params.layer_region.is_none());
assert!(no_params.into_body_world_state().regions.is_empty());
}
}
+8 -7
View File
@@ -407,13 +407,14 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
} else {
hm
};
// Run the full cascade through RoadGraph (Layer 2, T-1038), the
// terminal layer. It subsumes Settlement, DistrictProfile (T-1023),
// and all prior layers. DistrictProfile derivation still gates on
// body_params internally (skipped when absent — e.g. a body with no
// params row), but the road graph needs no body params, so it runs
// for every analyzed body.
let up_to = CascadeLayer::RoadGraph;
// Run the full cascade through Region (T-1113), the terminal
// layer. It subsumes RoadGraph (T-1038), Settlement,
// DistrictProfile (T-1023), and all prior layers.
// DistrictProfile and Region derivation both gate on
// body_params internally (skipped when absent — e.g. a body
// with no params row), but the road graph needs no body
// params, so it runs for every analyzed body.
let up_to = CascadeLayer::Region;
let snapshot = run_cascade_from_heightmap(
*body_seed,
working,
+185 -6
View File
@@ -31,8 +31,8 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// A client request for a body's generation layers (D-225).
///
/// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item`
/// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::RoadGraph`
/// (the terminal layer, T-1038) unconditionally on every request, ignoring this
/// (`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)]
@@ -70,13 +70,13 @@ pub struct DistrictGridLayer {
}
/// A layer response: the computed `Layer1Output` + the coarse district grid
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
/// a non-ready status.
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) +
/// the region climate grid (T-1113), or a non-ready status.
///
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
/// rules out any L5/tile Atlas layer ever, leaving T-1112 (quarter
/// footprints) and T-1113 (region climate) as the only remaining candidates.
/// footprints) as the only remaining candidate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasLayerResponse {
pub body_id: String,
@@ -94,6 +94,9 @@ pub struct AtlasLayerResponse {
/// 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>,
}
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
@@ -129,6 +132,83 @@ pub fn build_district_grid(
})
}
// ---------------------------------------------------------------------------
// 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 0100 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,
})
}
// ---------------------------------------------------------------------------
// RoadGraphLayer (T-960 §1, T-1038)
// ---------------------------------------------------------------------------
@@ -341,6 +421,7 @@ pub fn handle_atlas_request(
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);
return AtlasLayerResponse {
body_id: req.body_id.clone(),
status: AtlasLayerStatus::Ready,
@@ -348,6 +429,7 @@ pub fn handle_atlas_request(
district_grid,
road_graph,
settlements,
region_grid,
};
}
@@ -420,6 +502,7 @@ pub fn handle_atlas_request(
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
}
}
// Unknown / no terrain → re-requesting won't help.
@@ -431,6 +514,7 @@ pub fn handle_atlas_request(
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
},
Err(e) => AtlasLayerResponse {
body_id: req.body_id.clone(),
@@ -439,6 +523,7 @@ pub fn handle_atlas_request(
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
},
}
}
@@ -487,6 +572,7 @@ mod tests {
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.
@@ -512,6 +598,64 @@ mod tests {
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);
}
/// 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 {
@@ -527,6 +671,7 @@ mod tests {
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,
}
}
@@ -728,6 +873,7 @@ mod tests {
district_grid: None,
road_graph: build_road_graph_layer(&state),
settlements: build_settlement_layer(&state),
region_grid: build_region_grid(&state),
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
@@ -829,6 +975,7 @@ mod tests {
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();
@@ -957,7 +1104,11 @@ mod tests {
}
/// With body_params_reader wired, a cache miss enqueues an AnalyzeBody that
/// completes with populated `districts` (DistrictProfile layer ran).
/// 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);
@@ -995,6 +1146,29 @@ mod tests {
!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,
&queue,
&resolver,
None,
Some(&params_reader),
42,
2,
);
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.
@@ -1034,5 +1208,10 @@ mod tests {
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)"
);
}
}
+1
View File
@@ -113,6 +113,7 @@ fn serve_atlas_requests(
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
},
};
responses.0.push(resp);
+3
View File
@@ -624,6 +624,7 @@ fn generate_atlas_layer_response_fixtures() {
district_grid: None,
road_graph: Some(road_graph),
settlements: Some(settlements),
region_grid: None,
};
write_fixture(
"atlas_response_ready",
@@ -637,6 +638,7 @@ fn generate_atlas_layer_response_fixtures() {
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
};
write_fixture(
"atlas_response_pending",
@@ -650,6 +652,7 @@ fn generate_atlas_layer_response_fixtures() {
district_grid: None,
road_graph: None,
settlements: None,
region_grid: None,
};
write_fixture(
"atlas_response_not_found",