feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline
Ten-module atlas package implementing the D-194–D-218 district generation stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue, five-phase attractor-matching, three-component district mix, block irregularity, tile condition thresholds, and the Phase 1 skeleton generator that wires them into DistrictSkeleton. Closes #916 #917 #918 #919 #920 #922 #923 #924 #899. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214).
|
||||
//!
|
||||
//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`]
|
||||
//! together with the city's raw population and economic role, and produces a
|
||||
//! fully classified [`DistrictSkeleton`] with:
|
||||
//!
|
||||
//! - [`SettingType`] derived from the surrounding biome context.
|
||||
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
|
||||
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
|
||||
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
|
||||
//! algorithm (D-194).
|
||||
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
|
||||
//! terminals (transit_hub role or pop tier ≥ 3).
|
||||
//!
|
||||
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
|
||||
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
|
||||
//!
|
||||
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
|
||||
//! in block assignment.
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, ComplexityTier, DistrictId, DistrictLayoutMode,
|
||||
DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
|
||||
CityGenerationContext,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `context`: Build-time city context (archetype, world tier, orientation…).
|
||||
/// - `population`: Raw population count from atlas_city_names.
|
||||
/// - `economic_role`: Economic role string (one of the 10 canonical values).
|
||||
/// - `district_id`: Content-addressable identifier for this district.
|
||||
/// - `founding_age_years`: Years since founding — controls block irregularity.
|
||||
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
|
||||
pub fn generate_skeleton(
|
||||
context: &CityGenerationContext,
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
district_id: DistrictId,
|
||||
founding_age_years: u32,
|
||||
seed: u64,
|
||||
) -> DistrictSkeleton {
|
||||
// ── 1. SettingType ────────────────────────────────────────────────────
|
||||
// Pass through the surrounding_biome from context — it already encodes
|
||||
// the planet/station/wilderness classification established at atlas time.
|
||||
let setting = derive_setting(&context.surrounding_biome, economic_role);
|
||||
|
||||
// ── 2. ComplexityTier ─────────────────────────────────────────────────
|
||||
let tier = population_tier(population);
|
||||
let complexity = derive_complexity(&context.world_tier, tier, population);
|
||||
|
||||
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
|
||||
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
|
||||
|
||||
// ── 4. District mix → block grid ─────────────────────────────────────
|
||||
// A single district occupies a 4×4 block grid = 16 blocks.
|
||||
let total_blocks: u32 = 16;
|
||||
let mix = compute_district_mix(
|
||||
population,
|
||||
economic_role,
|
||||
&context.political_archetype,
|
||||
total_blocks,
|
||||
seed,
|
||||
);
|
||||
|
||||
// ── 5. Multi-block reservations ───────────────────────────────────────
|
||||
let reservations = derive_reservations(tier, economic_role, seed);
|
||||
|
||||
// Build the reservation lookup: block position → reservation id.
|
||||
let mut block_reservation: [[Option<ReservationId>; 4]; 4] =
|
||||
[[None, None, None, None]; 4];
|
||||
for (idx, res) in reservations.iter().enumerate() {
|
||||
let rid = idx as u64 + 1; // 1-based stable id within this district
|
||||
for &(row, col) in &res.blocks {
|
||||
let r = row as usize;
|
||||
let c = col as usize;
|
||||
if r < 4 && c < 4 {
|
||||
block_reservation[r][c] = Some(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build 4×4 block grid ──────────────────────────────────────────────
|
||||
// Flat district-mix list is already in deterministic order; assign
|
||||
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
|
||||
let primary_district_type = mix.districts.first().cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
|
||||
// ── Compute z_levels ──────────────────────────────────────────────────
|
||||
// Phase 1: single-storey above ground for all non-reserved blocks.
|
||||
// Reserved blocks carry their own z_levels count.
|
||||
let z_levels: u8 = 1;
|
||||
|
||||
DistrictSkeleton {
|
||||
district_id,
|
||||
seed,
|
||||
district_type: district_type_from_mix(&primary_district_type),
|
||||
context: String::new(), // stub — DistrictContext = String
|
||||
world_tier: context.world_tier.clone(),
|
||||
complexity,
|
||||
setting,
|
||||
layout_mode,
|
||||
blocks,
|
||||
reservations,
|
||||
corridors: Vec::new(),
|
||||
z_levels,
|
||||
social_sites: Vec::new(),
|
||||
access_points: Vec::new(),
|
||||
society_profile: String::new(),
|
||||
zone_palette: Vec::new(),
|
||||
boundaries: String::new(),
|
||||
guarantee_audit: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SettingType derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive SettingType from the city's surrounding biome context.
|
||||
///
|
||||
/// The surrounding_biome on CityGenerationContext already encodes the
|
||||
/// planet/station classification. For city districts we map it to
|
||||
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
|
||||
/// through directly.
|
||||
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
|
||||
match surrounding_biome {
|
||||
// Station bodies → always Station setting regardless of role.
|
||||
SettingType::Station => SettingType::Station,
|
||||
// Orbital platforms.
|
||||
SettingType::Orbital => SettingType::Orbital,
|
||||
// Maritime worlds — coastal city districts are Maritime.
|
||||
SettingType::Maritime => SettingType::Maritime,
|
||||
// Agricultural worlds → Agricultural districts.
|
||||
SettingType::Agricultural => SettingType::Agricultural,
|
||||
// For all other planet classes, city districts are Urban.
|
||||
// Exception: extraction role on wilderness worlds → Specialized.
|
||||
SettingType::Wilderness { biome } => {
|
||||
if economic_role == "extraction" {
|
||||
SettingType::Specialized {
|
||||
function: format!("extraction-{biome}"),
|
||||
}
|
||||
} else {
|
||||
SettingType::Urban
|
||||
}
|
||||
}
|
||||
// Transit nodes get Transitional setting.
|
||||
SettingType::Transitional => SettingType::Transitional,
|
||||
// Water bodies → Water districts don't host cities; treat as Specialized.
|
||||
SettingType::Water { .. } => SettingType::Specialized {
|
||||
function: "waterfront".into(),
|
||||
},
|
||||
// Generic Specialized pass-through.
|
||||
SettingType::Specialized { function } => SettingType::Specialized {
|
||||
function: function.clone(),
|
||||
},
|
||||
// Default for Urban and any unknown variant: Urban.
|
||||
SettingType::Urban => SettingType::Urban,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComplexityTier derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
|
||||
///
|
||||
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
|
||||
/// |-----------------|---------------|----------------------|
|
||||
/// | Epicenter | Full | Moderate |
|
||||
/// | Regional | Full | Moderate |
|
||||
/// | Backwater | Moderate | Minimal |
|
||||
/// | Passage | Moderate | Minimal |
|
||||
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
|
||||
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
|
||||
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
|
||||
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
|
||||
return ComplexityTier::Empty;
|
||||
}
|
||||
|
||||
match world_tier {
|
||||
WorldTier::Epicenter | WorldTier::Regional => {
|
||||
if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate }
|
||||
}
|
||||
WorldTier::Backwater | WorldTier::Passage => {
|
||||
if pop_tier >= 1 { ComplexityTier::Moderate } else { ComplexityTier::Minimal }
|
||||
}
|
||||
WorldTier::Waypoint => ComplexityTier::Minimal,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DistrictLayoutMode derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
|
||||
///
|
||||
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
|
||||
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
|
||||
fn derive_layout_mode(
|
||||
archetype: &PoliticalArchetype,
|
||||
irregularity: f32,
|
||||
seed: u64,
|
||||
) -> DistrictLayoutMode {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission
|
||||
| PoliticalArchetype::Military
|
||||
| PoliticalArchetype::Corporate
|
||||
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
|
||||
|
||||
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
|
||||
// Organic: generate per-block offsets and rotations seeded from district seed.
|
||||
let placements = organic_placements(irregularity, seed);
|
||||
DistrictLayoutMode::Organic { placements }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate 4×4 organic block placements seeded deterministically (D-010).
|
||||
///
|
||||
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = SkeletonLcg::new(seed);
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
offset: (0, 0),
|
||||
rotation_steps: 0,
|
||||
street_width_bps: 10_000,
|
||||
});
|
||||
|
||||
for item in flat.iter_mut() {
|
||||
let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
|
||||
let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°)
|
||||
// Street width 7500–20000 bps proportional to irregularity.
|
||||
let width_range = 12_500u32; // 20000 - 7500
|
||||
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
|
||||
*item = BlockPlacement {
|
||||
offset: (raw_x, raw_y),
|
||||
rotation_steps: rot,
|
||||
street_width_bps: width as u16,
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4].
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| flat[row * 4 + col].clone())
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block grid construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map a DistrictType to its primary ZoningType (D-194).
|
||||
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => ZoningType::Industrial,
|
||||
DistrictType::Residential => ZoningType::Residential,
|
||||
DistrictType::Commercial => ZoningType::Commercial,
|
||||
DistrictType::Industrial => ZoningType::Industrial,
|
||||
DistrictType::Administrative => ZoningType::Administrative,
|
||||
DistrictType::Entertainment => ZoningType::Commercial,
|
||||
DistrictType::MixedUse => ZoningType::Mixed,
|
||||
DistrictType::Transit => ZoningType::Transit,
|
||||
DistrictType::Specialized => ZoningType::Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the primary district type to the DistrictType field on DistrictSkeleton.
|
||||
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
|
||||
primary.clone()
|
||||
}
|
||||
|
||||
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
|
||||
///
|
||||
/// Blocks are assigned row-major (index = row * 4 + col).
|
||||
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
|
||||
fn build_block_grid(
|
||||
districts: &[DistrictType],
|
||||
block_reservation: &[[Option<ReservationId>; 4]; 4],
|
||||
primary: &DistrictType,
|
||||
) -> [[BlockSkeleton; 4]; 4] {
|
||||
// Pad or truncate district list to exactly 16.
|
||||
let district_iter: Vec<&DistrictType> = (0..16)
|
||||
.map(|i| districts.get(i).unwrap_or(primary))
|
||||
.collect();
|
||||
|
||||
core::array::from_fn(|row| {
|
||||
core::array::from_fn(|col| {
|
||||
let idx = row * 4 + col;
|
||||
let dt = district_iter[idx];
|
||||
let zoning = zoning_for_district(dt);
|
||||
let reservation = block_reservation[row][col];
|
||||
|
||||
let density = density_for_zoning(&zoning);
|
||||
BlockSkeleton {
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
reservation,
|
||||
chunk_layout: String::new(), // stub
|
||||
hosted_sites: Vec::new(),
|
||||
era: String::new(), // stub
|
||||
era_modifications: Vec::new(),
|
||||
era_cause: None,
|
||||
density_pct: density,
|
||||
landmark: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Default build density percentage for a zoning type.
|
||||
fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
match zoning {
|
||||
ZoningType::Residential => 60,
|
||||
ZoningType::Commercial => 80,
|
||||
ZoningType::Industrial => 70,
|
||||
ZoningType::Administrative => 75,
|
||||
ZoningType::Transit => 50,
|
||||
ZoningType::Recreational => 30,
|
||||
ZoningType::Restricted => 85,
|
||||
ZoningType::Mixed => 65,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-block reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
|
||||
///
|
||||
/// Reservation rules:
|
||||
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
|
||||
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1.
|
||||
///
|
||||
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
|
||||
/// are deferred to Phase 2.
|
||||
fn derive_reservations(pop_tier: u8, economic_role: &str, _seed: u64) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Park: large cities need open space.
|
||||
if pop_tier >= 2 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
|
||||
template_tag: "park-central".into(),
|
||||
function: ReservationFunction::Park,
|
||||
z_levels: 1,
|
||||
base_z: 0,
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 1,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Transit terminal: transit-hub economies and major cities.
|
||||
if economic_role == "transit_hub" || pop_tier >= 3 {
|
||||
out.push(MultiBlockReservation {
|
||||
blocks: vec![(0, 0), (0, 1)],
|
||||
template_tag: "transit-terminal".into(),
|
||||
function: ReservationFunction::Terminal,
|
||||
z_levels: 2,
|
||||
base_z: -1, // one level of underground rail
|
||||
floor_zones: Vec::new(),
|
||||
z_band_count: 2,
|
||||
z_band_zones: Vec::new(),
|
||||
vertical_corridors: Vec::new(),
|
||||
hosted_sites: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal seeded LCG (D-010 determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SkeletonLcg {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl SkeletonLcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Mix seed to avoid degenerate state at 0.
|
||||
Self { state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15) }
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{
|
||||
CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier,
|
||||
};
|
||||
|
||||
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
|
||||
CityGenerationContext {
|
||||
city_id: 1,
|
||||
political_archetype: archetype,
|
||||
prosperity_baseline: 0.7,
|
||||
surrounding_biome: SettingType::Urban,
|
||||
road_entry_directions: vec![0, 4],
|
||||
footprint_radius_km: 10.0,
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
world_tier,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_station_passthrough() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.surrounding_biome = SettingType::Station;
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Station));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_urban_for_city_on_planet() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
assert!(matches!(sk.setting, SettingType::Urban));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_epicenter_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 10M pop → pop_tier = 1 → Full on Epicenter
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_waypoint_tiny_pop_is_empty() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Backwater
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commission_is_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_pioneer_is_organic() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_grid_is_fully_populated() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
// All 16 blocks must have valid positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
let b = &sk.blocks[row][col];
|
||||
assert_eq!(b.position, (row as u8, col as u8));
|
||||
assert!(b.density_pct <= 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_reservations_for_small_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// pop_tier 0, not transit_hub → no reservations.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
|
||||
assert!(sk.reservations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn park_reservation_for_large_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → pop_tier 2 → park reservation.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let has_park = sk.reservations.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Park));
|
||||
assert!(has_park);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transit_terminal_for_transit_hub_role() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// pop_tier 0 but transit_hub → terminal reservation.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
|
||||
let has_terminal = sk.reservations.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Terminal));
|
||||
assert!(has_terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_blocks_linked_in_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
// All park blocks must reference the park reservation (id=1).
|
||||
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
let b = &sk.blocks[row as usize][col as usize];
|
||||
assert!(b.reservation.is_some(), "block ({row},{col}) should be reserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed_same_output() {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
// Compare block grid zoning and positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
|
||||
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
|
||||
assert_eq!(sk1.blocks[row][col].density_pct, sk2.blocks[row][col].density_pct);
|
||||
}
|
||||
}
|
||||
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user