diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index b45ad1b02..91f93680c 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -‡§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ \ No newline at end of file +ˆ§body_id¥ghost¦status¨NotFound¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack index 2ad9d65e5..0ea79bf10 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -‡§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ \ No newline at end of file +ˆ§body_id¤GJ1c¦status§Pending¦layer1À­district_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ²quarter_footprintsÀ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack index 14c10caed..bd8d0486c 100644 Binary files a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack and b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack differ diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 011e89f2f..cab1fc57e 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -21,8 +21,8 @@ use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; -use crate::seed::SeedChain; -use crate::simulation::generator::{AttractorType, MaintenanceAuthority}; +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). @@ -71,12 +71,16 @@ 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) + -/// the region climate grid (T-1113), or a non-ready status. +/// the region climate grid (T-1113) + the quarter-footprint overlay (T-1112, +/// T-1119), 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) as the only remaining candidate. +/// pattern tops out at six fields — 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. **The budget is now +/// consumed** — a seventh field is not a naming exercise like the six before +/// it; what (if anything) carries a future generation-layer addition is a +/// T-1124 design-pass question, not something to resolve here. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, @@ -97,6 +101,13 @@ pub struct AtlasLayerResponse { /// 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, + /// 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, } /// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046). @@ -209,6 +220,148 @@ pub fn build_region_grid( }) } +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, +) -> 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 { + 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) // --------------------------------------------------------------------------- @@ -422,6 +575,7 @@ pub fn handle_atlas_request( 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, @@ -430,6 +584,7 @@ pub fn handle_atlas_request( road_graph, settlements, region_grid, + quarter_footprints, }; } @@ -503,6 +658,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, } } // Unknown / no terrain → re-requesting won't help. @@ -515,6 +671,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, Err(e) => AtlasLayerResponse { body_id: req.body_id.clone(), @@ -524,6 +681,7 @@ pub fn handle_atlas_request( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, } } @@ -656,6 +814,338 @@ mod tests { assert_eq!(grid.moisture_q[1], 5); } + /// 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::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 { @@ -819,7 +1309,7 @@ mod tests { assert!(build_settlement_layer(&unrun).is_none()); } - /// T-960: the new layers survive a MessagePack round trip inside + /// 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). @@ -828,7 +1318,9 @@ mod tests { use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode}; use crate::simulation::generator::{ - ArrangementPattern, FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, + ArrangementPattern, BlockSkeleton, DistrictType, FoundingOrientation, + MaintenanceAuthority, PoliticalArchetype, QuarterSkeleton, QuarterWorldState, + ZoningType, }; let mut state = blank_state("GJ1c"); @@ -865,6 +1357,28 @@ mod tests { 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(), @@ -874,6 +1388,7 @@ mod tests { road_graph: build_road_graph_layer(&state), settlements: build_settlement_layer(&state), region_grid: build_region_grid(&state), + quarter_footprints: build_quarter_footprint_layer(&state, world_seed), }; let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); @@ -886,6 +1401,13 @@ mod tests { 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"); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index ce4e2813f..84b66f109 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -121,6 +121,7 @@ fn serve_atlas_requests( road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }, }; responses.0.push((conn_id, resp)); @@ -358,9 +359,7 @@ fn drain_generation_completions( // Key by state.skeleton.quarter_id (D-194/D-230): a city has many // quarters, each with its own QuarterId. `city_id` is only the // dispatch key used in the work item — the canonical insert key is - // the quarter's own stable id. TODO(#957): the stub GenerateSkeleton - // returns a default skeleton with quarter_id=0; real gen (#957) will - // populate it from CityGenerationContext. + // the quarter's own stable id. let _ = city_id; // used as dispatch key only; quarter_id is the map key body_state .quarters diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 4e694fa87..89682a079 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -231,7 +231,13 @@ pub enum DistrictLayoutMode { } /// Zoning classification for a block or floor zone. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +/// +/// `Ord`/`PartialOrd` (T-1119, mirroring `DistrictType`'s T-994 precedent above): +/// lets the quarter-footprint aggregate (`layer_proxy::build_quarter_footprint_layer`) +/// resolve a `dominant_zoning` mode-tie by lowest declaration-order variant. +/// Declaration order is not a stability-pinned wire format on this enum (unlike +/// `MorphologyZone` or `SeedDomain`), so this is a safe additive derive. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum ZoningType { Commercial, Residential, diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 9a60a9808..601a33528 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -4,16 +4,20 @@ use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork}; use settled_reach_server::atlas::layer1::Layer1Output; use settled_reach_server::atlas::layer_proxy::{ - AtlasLayerResponse, AtlasLayerStatus, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, - SettlementEntry, SettlementLayer, SettlementSizeClass, + AtlasLayerResponse, AtlasLayerStatus, QuarterFootprintEntry, QuarterFootprintLayer, + RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, + SettlementLayer, SettlementSizeClass, }; +use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; use settled_reach_server::bridge::types::*; use settled_reach_server::simulation::generator::{ - AttractorType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant, + AttractorType, DistrictType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant, + ZoningType, }; use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; +use std::collections::BTreeMap; use std::fs; use std::path::Path; @@ -616,6 +620,53 @@ fn generate_atlas_layer_response_fixtures() { }, ], }; + // T-1113: a small populated RegionGridLayer — a 2×1 covering grid, one + // temperate region and one airless-style region (mean_temp_dc sentinel), + // mirroring the values exercised by + // `build_region_grid_encodes_dense_quantized_climate` in layer_proxy.rs. + let region_grid = RegionGridLayer { + cols: 2, + rows: 1, + season: vec![SeasonPhase::Summer as u8, SeasonPhase::Winter as u8], + weather: vec![WeatherState::Clear as u8, WeatherState::Snow as u8], + mean_temp_dc: vec![ + 123, + settled_reach_server::atlas::layer_proxy::REGION_TEMP_NONE_DC, + ], + moisture_q: vec![80, 5], + }; + // T-1119 (D-226 T-1112 amendment): a populated QuarterFootprintLayer + // covering both fixture settlements — city_id 1 (Port Aldren, a dense + // Commercial-dominant quarter with landmarks/corridors) and city_id 2 + // (Farmstead Rell, a sparse Residential-dominant quarter with neither), + // so the fixture exercises both a "rich" entry and a "minimal" entry + // rather than only one shape. + let quarter_footprints = QuarterFootprintLayer { + entries: BTreeMap::from([ + ( + 1, + QuarterFootprintEntry { + city_id: 1, + density_avg_pct: 62, + dominant_district_type: DistrictType::Commercial, + dominant_zoning: ZoningType::Commercial, + landmark_count: 3, + corridor_count: 4, + }, + ), + ( + 2, + QuarterFootprintEntry { + city_id: 2, + density_avg_pct: 18, + dominant_district_type: DistrictType::Residential, + dominant_zoning: ZoningType::Residential, + landmark_count: 0, + corridor_count: 1, + }, + ), + ]), + }; let ready = AtlasLayerResponse { body_id: "GJ1c".into(), @@ -624,7 +675,8 @@ fn generate_atlas_layer_response_fixtures() { district_grid: None, road_graph: Some(road_graph), settlements: Some(settlements), - region_grid: None, + region_grid: Some(region_grid), + quarter_footprints: Some(quarter_footprints), }; write_fixture( "atlas_response_ready", @@ -639,6 +691,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }; write_fixture( "atlas_response_pending", @@ -653,6 +706,7 @@ fn generate_atlas_layer_response_fixtures() { road_graph: None, settlements: None, region_grid: None, + quarter_footprints: None, }; write_fixture( "atlas_response_not_found",