feat(simulation): building-property-tag derivation helpers (#957)

The pure per-footprint tag derivations for Layer-4 quarter generation (D-229),
ahead of the footprint-subdivision + assembly step:
- zone_type_for: the authored (ZoningType × economic_role × setting) → ZoneTypeId
  selection (D-229 amendment) — planetary variants only, setting as a tweaker,
  station-only ids never selected (Q-109); deterministic seed-pick, no empty slice.
- building_entry_class: zone × layout_mode × prosperity, with the D-217 Broken-band
  U-curve degrade (Commercial→BreachOnly) and the Grid/Organic credential fork.
- construction_era: founding_age + prosperity + seed (D-229); old+Broken → Derelict.
- floor_extent: D-220 density-class floor ranges from density_pct + seed-jitter.
- initial_condition: D-217 frozen-amber condition from prosperity + era cause.

All integer-deterministic (D-010). 9 unit tests incl. the no-station-ids invariant.
Footprint subdivision + assign_building_tags assembly + block_tags wiring follow
(needs the footprint-packing model decided).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:15:46 +02:00
co-authored by Claude Opus 4.8
parent 43888f8a99
commit 1909e15edb
+458 -3
View File
@@ -20,11 +20,14 @@
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::tile_condition::{tile_condition, TileCondition};
use crate::seed::splitmix64;
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictLayoutMode,
DistrictType, MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton,
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
BlockPlacement, BlockSkeleton, BuildingEntryClass, CityGenerationContext, ComplexityTier,
ConstructionEra, DistrictLayoutMode, DistrictType, EraCause, FloorExtent, FloorHeightProfile,
MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction,
ReservationId, SettingType, WorldTier, ZoneTypeId, ZoningType,
};
// ---------------------------------------------------------------------------
@@ -403,6 +406,262 @@ fn derive_reservations(
out
}
// ---------------------------------------------------------------------------
// Building-property-tag derivation (D-229, #957)
//
// Pure functions producing the per-footprint tag fields. The footprint
// subdivision + assembly into QuarterWorldState.block_tags is the next step;
// these are the field-level derivations the D-229 amendment (2026-06-05) pins.
// ---------------------------------------------------------------------------
/// D-217 condition bands as integer basis points (prosperity_baseline_bps,
/// 10_000 = 1.0): Intact > 6300, Worn 43006300, Cracked 23004300, Broken < 2300.
const PROSPERITY_BROKEN_BPS: u32 = 2300;
/// Select the `ZoneTypeId` for one footprint (D-229 amendment, #957).
///
/// Builds a candidate slice from the `(ZoningType × economic_role)` base table,
/// applies the `setting` tweaker (planetary variants only — station/orbital ids
/// belong to a separate cascade, Q-109), then deterministically seed-picks one.
/// `seed` should be derived per footprint by the caller so footprints in a block
/// differ. Every branch yields a non-empty slice.
pub fn zone_type_for(
zoning: &ZoningType,
economic_role: &str,
setting: &SettingType,
seed: SeedChain,
) -> ZoneTypeId {
use ZoningType::*;
// Base candidate slice: ZoningType default, refined by economic_role.
let mut slice: Vec<&'static str> = match (zoning, economic_role) {
(Commercial, "financial") => vec!["diplomatic_elite", "commercial_market"],
(Commercial, "transit_hub") => vec!["commercial_transit", "commercial_market"],
(Commercial, "service_mixed") => {
vec![
"commercial_market",
"entertainment_hospitality",
"entertainment_venue",
]
}
(Commercial, _) => vec!["commercial_market", "entertainment_hospitality"],
(Residential, "agricultural") => vec!["rural_agricultural", "rural_pastoral"],
(Residential, "extraction") => vec!["residential_dispersed", "residential_surface"],
(Residential, _) => vec!["residential_surface"],
(Industrial, "manufacturing") => vec!["industrial_manufacturing", "industrial_processing"],
(Industrial, "extraction") => vec!["extraction_surface", "industrial_processing"],
(Industrial, "agricultural") => vec!["industrial_processing"],
(Industrial, _) => vec!["industrial_manufacturing", "industrial_freight"],
(Administrative, "institutional") => {
vec![
"administrative_civil",
"administrative_judicial",
"diplomatic_elite",
]
}
(Administrative, "research") => vec!["research_station", "administrative_civil"],
(Administrative, "service_mixed") => vec!["administrative_civil", "medical_facility"],
(Administrative, _) => vec!["administrative_civil"],
(Transit, "transit_hub") => vec!["commercial_transit", "port_surface"],
(Transit, "manufacturing") | (Transit, "extraction") => {
vec!["industrial_freight", "port_surface"]
}
(Transit, _) => vec!["port_surface"],
(Recreational, "research") | (Recreational, "institutional") => {
vec!["archaeological_site", "entertainment_venue"]
}
(Recreational, _) => vec!["entertainment_venue", "entertainment_hospitality"],
(Restricted, "military") => {
vec![
"military_garrison",
"security_checkpoint",
"detention_facility",
]
}
(Restricted, "research") => vec!["research_station", "security_checkpoint"],
(Restricted, "institutional") => vec!["detention_facility", "security_checkpoint"],
(Restricted, _) => vec!["security_checkpoint"],
(Mixed, _) => vec![
"residential_surface",
"commercial_market",
"administrative_civil",
],
};
apply_setting_tweaker(&mut slice, zoning, setting);
// Deterministic pick (D-010): slice is never empty.
let idx = (splitmix64(seed.seed()) % slice.len() as u64) as usize;
ZoneTypeId::new(slice[idx])
}
/// Apply the `setting` tweaker to a base candidate slice (D-229 amendment).
/// Tweaks planetary variants only; never introduces station/orbital ids.
fn apply_setting_tweaker(
slice: &mut Vec<&'static str>,
zoning: &ZoningType,
setting: &SettingType,
) {
match setting {
SettingType::Maritime | SettingType::Water { .. } => {
for id in slice.iter_mut() {
*id = match *id {
"port_surface" => "port_maritime",
"extraction_surface" => "extraction_platform",
"rural_agricultural" | "rural_pastoral" => "rural_aquaculture",
other => other,
};
}
if matches!(zoning, ZoningType::Transit) {
slice.push("port_fishing");
}
}
SettingType::Agricultural => {
if matches!(zoning, ZoningType::Residential | ZoningType::Mixed) {
slice.insert(0, "rural_pastoral");
slice.insert(0, "rural_agricultural");
}
}
SettingType::Wilderness { .. } => {
if matches!(zoning, ZoningType::Residential | ZoningType::Mixed) {
slice.insert(0, "residential_dispersed");
slice.insert(0, "wilderness_frontier");
}
}
// Urban / Station / Orbital / Transitional / Specialized: base unchanged.
// (Station/Orbital bodies never reach this planetary cascade — Q-109.)
_ => {}
}
}
/// Derive `BuildingEntryClass` from zone × layout_mode × prosperity (D-229).
///
/// U-curve degrade: a normally-accessible zone at Broken prosperity seals to
/// `BreachOnly`. The Commission(Grid)/Organic fork softens credentialed zones —
/// Organic settlements use informal/social access (Public) where Grid settlements
/// require a formal credential (Restricted). The credential *type* (formal vs
/// social) is a door-level concern (D-231, #979).
pub fn building_entry_class(
zoning: &ZoningType,
layout_mode: &DistrictLayoutMode,
prosperity_bps: u32,
) -> BuildingEntryClass {
use BuildingEntryClass as E;
let derelict = prosperity_bps < PROSPERITY_BROKEN_BPS;
let organic = matches!(layout_mode, DistrictLayoutMode::Organic { .. });
match zoning {
ZoningType::Commercial => {
if derelict {
E::BreachOnly
} else {
E::Commercial
}
}
ZoningType::Recreational | ZoningType::Transit => E::Public,
ZoningType::Administrative | ZoningType::Residential => {
if organic {
E::Public
} else {
E::Restricted
}
}
ZoningType::Industrial => E::Restricted,
ZoningType::Restricted => {
if derelict {
E::BreachOnly
} else {
E::Restricted
}
}
ZoningType::Mixed => {
if derelict {
E::Restricted
} else {
E::Public
}
}
}
}
/// Derive `(ConstructionEra, EraCause)` from founding age + prosperity + seed
/// (D-229). Founding age anchors the distribution; the seed scatters outliers.
/// A long-settled, Broken-prosperity footprint reads as `Derelict`.
pub fn construction_era(
founding_age_years: u32,
prosperity_bps: u32,
seed: SeedChain,
) -> (ConstructionEra, EraCause) {
use ConstructionEra::*;
if prosperity_bps < PROSPERITY_BROKEN_BPS && founding_age_years > 200 {
// Degraded beyond economic viability → its decay is the era's cause.
return (Derelict, EraCause::EconomicDisruption);
}
let roll = splitmix64(seed.seed()) % 100;
let era = match founding_age_years {
0..=80 => {
if roll < 70 {
Modern
} else {
Established
}
}
81..=300 => {
if roll < 40 {
Established
} else if roll < 70 {
Modern
} else {
Founding
}
}
_ => {
if roll < 50 {
Founding
} else if roll < 80 {
Established
} else {
Modern
}
}
};
(era, EraCause::Original)
}
/// Derive `FloorExtent` from build density + seed (D-220 density classes).
///
/// `density_pct` (the block's build density) proxies the D-220 density class →
/// floor-count range; the seed jitters within the range, and denser blocks gain
/// basement levels (negative `base_floor`, D-110). `Uniform(3)` ≈ 3 m/floor.
pub fn floor_extent(density_pct: u8, seed: SeedChain) -> FloorExtent {
let (min_f, max_f, base_floor) = match density_pct {
0..=20 => (1u8, 1u8, 0i8), // Frontier — 1 floor, no basement
21..=45 => (1, 2, 0), // Settled — 12 floors
46..=65 => (2, 3, -1), // Established — basement common
66..=85 => (3, 6, -1), // Dense — 1 basement
_ => (6, 12, -2), // Compressed — 2 basement levels
};
let span = (max_f - min_f + 1) as u64;
let floor_count = min_f + (splitmix64(seed.seed()) % span) as u8;
FloorExtent {
base_floor,
floor_count,
heights: FloorHeightProfile::Uniform(3),
}
}
/// Frozen-amber `initial_condition` from prosperity + era cause (D-197/D-217).
/// Thin wrapper over the canonical D-217 derivation; the rolling overlay (D-198)
/// paints over this without mutating the tag.
pub fn initial_condition(prosperity_bps: u32, era_cause: &EraCause) -> TileCondition {
tile_condition(prosperity_bps as f32 / 10_000.0, Some(era_cause))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -603,4 +862,200 @@ mod tests {
}
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
}
// ── Building-property-tag derivation (D-229, #957) ───────────────────────
use crate::simulation::generator::{BuildingEntryClass, ConstructionEra, EraCause, ZoningType};
fn seed() -> SeedChain {
SeedChain::root(42)
}
#[test]
fn zone_type_role_refinement() {
// economic_role refines the slice within a ZoningType.
let z = zone_type_for(
&ZoningType::Industrial,
"manufacturing",
&SettingType::Urban,
seed(),
);
assert!(["industrial_manufacturing", "industrial_processing"].contains(&z.as_str()));
let r = zone_type_for(
&ZoningType::Restricted,
"military",
&SettingType::Urban,
seed(),
);
assert!([
"military_garrison",
"security_checkpoint",
"detention_facility"
]
.contains(&r.as_str()));
}
#[test]
fn zone_type_unknown_role_uses_default() {
let z = zone_type_for(
&ZoningType::Commercial,
"totally_unknown_role",
&SettingType::Urban,
seed(),
);
assert!(["commercial_market", "entertainment_hospitality"].contains(&z.as_str()));
}
#[test]
fn zone_type_maritime_tweaker_swaps_to_water_variants() {
// Transit on a Maritime body → port_maritime / port_fishing, never port_surface.
let mut got = std::collections::BTreeSet::new();
for s in 0..40u64 {
let z = zone_type_for(
&ZoningType::Transit,
"transit_hub",
&SettingType::Maritime,
SeedChain::root(s),
);
got.insert(z.as_str().to_string());
}
assert!(
!got.contains("port_surface"),
"maritime must not yield port_surface: {got:?}"
);
assert!(got
.iter()
.all(|id| id == "port_maritime" || id == "port_fishing" || id == "commercial_transit"));
}
#[test]
fn zone_type_never_yields_station_only_ids() {
// Station/orbital ids belong to the separate cascade (Q-109) — never selected here.
let banned = [
"residential_station",
"extraction_space",
"port_space",
"rural_orbital",
];
let settings = [
SettingType::Urban,
SettingType::Maritime,
SettingType::Agricultural,
SettingType::Wilderness {
biome: Default::default(),
},
];
let roles = [
"manufacturing",
"extraction",
"residential",
"agricultural",
"transit_hub",
];
for set in &settings {
for role in &roles {
for zoning in [
ZoningType::Residential,
ZoningType::Industrial,
ZoningType::Transit,
ZoningType::Mixed,
] {
for s in 0..8u64 {
let z = zone_type_for(&zoning, role, set, SeedChain::root(s));
assert!(!banned.contains(&z.as_str()), "{z} is station-only");
}
}
}
}
}
#[test]
fn zone_type_is_deterministic() {
let a = zone_type_for(
&ZoningType::Mixed,
"service_mixed",
&SettingType::Urban,
seed(),
);
let b = zone_type_for(
&ZoningType::Mixed,
"service_mixed",
&SettingType::Urban,
seed(),
);
assert_eq!(a, b);
}
#[test]
fn entry_class_u_curve_and_layout_fork() {
use BuildingEntryClass::*;
// Commercial at Broken prosperity → sealed.
assert_eq!(
building_entry_class(&ZoningType::Commercial, &DistrictLayoutMode::Grid, 1000),
BreachOnly
);
// Commercial healthy → Commercial.
assert_eq!(
building_entry_class(&ZoningType::Commercial, &DistrictLayoutMode::Grid, 7000),
Commercial
);
// Residential: Grid → formal Restricted; Organic → informal Public.
assert_eq!(
building_entry_class(&ZoningType::Residential, &DistrictLayoutMode::Grid, 7000),
Restricted
);
assert_eq!(
building_entry_class(
&ZoningType::Residential,
&DistrictLayoutMode::Organic {
placements: Default::default()
},
7000
),
Public
);
}
#[test]
fn construction_era_derelict_when_old_and_broken() {
let (era, cause) = construction_era(800, 1000, seed());
assert_eq!(era, ConstructionEra::Derelict);
assert_eq!(cause, EraCause::EconomicDisruption);
}
#[test]
fn construction_era_young_skews_modern_and_is_deterministic() {
let (era1, _) = construction_era(20, 7000, SeedChain::root(7));
let (era2, _) = construction_era(20, 7000, SeedChain::root(7));
assert_eq!(era1, era2);
assert!(matches!(
era1,
ConstructionEra::Modern | ConstructionEra::Established
));
}
#[test]
fn floor_extent_scales_with_density() {
// Frontier density → exactly 1 floor, no basement.
let low = floor_extent(10, seed());
assert_eq!(low.floor_count, 1);
assert_eq!(low.base_floor, 0);
// Dense → 36 floors with a basement.
let high = floor_extent(80, seed());
assert!((3..=6).contains(&high.floor_count));
assert_eq!(high.base_floor, -1);
}
#[test]
fn initial_condition_tracks_prosperity() {
use crate::atlas::tile_condition::TileCondition;
assert_eq!(
initial_condition(9000, &EraCause::Original),
TileCondition::Intact
);
assert_eq!(
initial_condition(1000, &EraCause::Original),
TileCondition::Broken
);
}
}