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:
@@ -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 4300–6300, Cracked 2300–4300, 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 — 1–2 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 → 3–6 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user