feat(simulation): T-1119 QuarterFootprintLayer — server half (touch points 1-2) + populated wire fixtures
ZoningType gains PartialOrd/Ord (DistrictType T-994 mirror) for the dominant_zoning declaration-order tie-break. layer_proxy: QuarterFootprintEntry/QuarterFootprintLayer (BTreeMap keyed by city_id; density_avg_pct floor-mean, dominant_* mode with declaration-order tie-break, landmark/corridor counts) + build_quarter_footprint_layer gated None on empty state.quarters; sixth AtlasLayerResponse field wired into all construction sites; growth-ceiling doc updated to say the whole-body budget is consumed (what carries a seventh is T-1124's design, resolved in the same branch's amendment). Placements whose derived Layer4Quarter id is not yet in state.quarters are SKIPPED (skeleton gen is async Low-priority — entries may be a proper subset; client decodes defensively). Stale TODO(#957) removed (premise predates T-957 landing). Tie-break bug caught by its own test: Iterator::max_by_key over a BTreeMap returns the LAST max on ties — backwards from declaration-order-first; replaced with an explicit forward walk that replaces only on strictly-greater count. gen_fixtures: populated RegionGridLayer (T-1118 replay coverage) + QuarterFootprintLayer literals in the ready fixture; msgpack fixtures regenerated via make fixtures. Note: landmark_count is inert on real data today (BlockSkeleton.landmark hardcoded None until landmark placement lands); fixtures use non-zero counts to exercise the logic. 5 new unit tests + populated round-trip; atlas:: 549 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RegionGridLayer>,
|
||||
/// 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).
|
||||
@@ -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<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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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::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 {
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user