chore(simulation): cargo fmt atlas modules
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,7 +99,11 @@ fn cell_score(
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 { 1.0 / terrain_cost } else { 1.0 };
|
||||
let cost_factor = if terrain_cost > 0.0 {
|
||||
1.0 / terrain_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
weight * attractor.strength * cost_factor
|
||||
}
|
||||
|
||||
@@ -213,11 +217,7 @@ fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
|
||||
/// Minimum pixel separation between synthetic attractor positions.
|
||||
const MIN_SPACING: u16 = 15;
|
||||
|
||||
fn synthetic_attractor(
|
||||
placed: &[CityPlacement],
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> GeographicAttractor {
|
||||
fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor {
|
||||
// Place at grid center as default, then walk until spacing is satisfied.
|
||||
let mut row = (grid_h / 2) as u16;
|
||||
let mut col = (grid_w / 4) as u16;
|
||||
@@ -326,9 +326,7 @@ pub fn match_cities(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, c)| {
|
||||
!tier_a_indices.contains(i)
|
||||
&& c.population >= 50_000
|
||||
&& c.population < 1_000_000
|
||||
!tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
@@ -450,12 +448,12 @@ pub fn founding_orientation(
|
||||
coastal_facing: u16,
|
||||
) -> FoundingOrientation {
|
||||
match attractor_type {
|
||||
AttractorType::RiverMouth | AttractorType::CoastalAccess => {
|
||||
FoundingOrientation::Coastal { facing_degrees: coastal_facing }
|
||||
}
|
||||
AttractorType::RiverCrossing => {
|
||||
FoundingOrientation::RiverAligned { bearing_degrees: river_bearing }
|
||||
}
|
||||
AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal {
|
||||
facing_degrees: coastal_facing,
|
||||
},
|
||||
AttractorType::RiverCrossing => FoundingOrientation::RiverAligned {
|
||||
bearing_degrees: river_bearing,
|
||||
},
|
||||
AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing,
|
||||
AttractorType::PlainCenter => {
|
||||
if matches!(territorial_status, TerritorialStatus::CommissionControlled) {
|
||||
@@ -480,11 +478,17 @@ mod tests {
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix { weights: [[1.0; 7]; 10] }
|
||||
CompatibilityMatrix {
|
||||
weights: [[1.0; 7]; 10],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
GeographicAttractor { position: (row, col), attractor_type: at, strength }
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: at,
|
||||
strength,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord {
|
||||
@@ -517,7 +521,7 @@ mod tests {
|
||||
make_city(2, SettlementClass::PopulationBudget, 200_000),
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
@@ -597,7 +601,12 @@ mod tests {
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
let status = TerritorialStatus::FrontierUnclaimed;
|
||||
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270);
|
||||
assert!(matches!(o, FoundingOrientation::Coastal { facing_degrees: 270 }));
|
||||
assert!(matches!(
|
||||
o,
|
||||
FoundingOrientation::Coastal {
|
||||
facing_degrees: 270
|
||||
}
|
||||
));
|
||||
|
||||
let o2 = founding_orientation(
|
||||
&AttractorType::PlainCenter,
|
||||
@@ -607,12 +616,7 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(o2, FoundingOrientation::Cardinal));
|
||||
|
||||
let o3 = founding_orientation(
|
||||
&AttractorType::ValleyFloor,
|
||||
&status,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0);
|
||||
assert!(matches!(o3, FoundingOrientation::TerrainFollowing));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,21 +74,25 @@ mod tests {
|
||||
fn pioneer_more_irregular_than_commission() {
|
||||
let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer);
|
||||
let commission = block_irregularity(400, &PoliticalArchetype::Commission);
|
||||
assert!(pioneer > commission,
|
||||
"Pioneer ({pioneer}) should be more irregular than Commission ({commission})");
|
||||
assert!(
|
||||
pioneer > commission,
|
||||
"Pioneer ({pioneer}) should be more irregular than Commission ({commission})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_increases_irregularity() {
|
||||
let young = block_irregularity(50, &PoliticalArchetype::Industrial);
|
||||
let old = block_irregularity(800, &PoliticalArchetype::Industrial);
|
||||
assert!(old > young,
|
||||
"Older settlement ({old}) should be more irregular than young ({young})");
|
||||
assert!(
|
||||
old > young,
|
||||
"Older settlement ({old}) should be more irregular than young ({young})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_offset_scales_with_irregularity() {
|
||||
assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0
|
||||
assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0
|
||||
assert_eq!(max_offset_sim_tiles(1.0), 16);
|
||||
assert_eq!(max_offset_sim_tiles(0.5), 8);
|
||||
}
|
||||
@@ -105,7 +109,11 @@ mod tests {
|
||||
];
|
||||
for a in &archetypes {
|
||||
let v = block_irregularity(300, a);
|
||||
assert!(v >= 0.05 && v <= 1.0, "archetype {:?} gave {v} out of [0.05, 1.0]", a);
|
||||
assert!(
|
||||
v >= 0.05 && v <= 1.0,
|
||||
"archetype {:?} gave {v} out of [0.05, 1.0]",
|
||||
a
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +236,14 @@ mod tests {
|
||||
cache.get("A", 100);
|
||||
// Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100).
|
||||
cache.insert(make_state("D", 4));
|
||||
assert!(cache.contains("A"), "A was recently accessed — must survive");
|
||||
assert!(!cache.contains("B"), "B had oldest access time — should be evicted");
|
||||
assert!(
|
||||
cache.contains("A"),
|
||||
"A was recently accessed — must survive"
|
||||
);
|
||||
assert!(
|
||||
!cache.contains("B"),
|
||||
"B had oldest access time — should be evicted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -246,12 +246,15 @@ struct LcgRng {
|
||||
|
||||
impl LcgRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed.wrapping_add(1) }
|
||||
Self {
|
||||
state: seed.wrapping_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
// LCG parameters from Knuth
|
||||
self.state = self.state
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
@@ -273,10 +276,10 @@ mod tests {
|
||||
#[test]
|
||||
fn population_tier_values() {
|
||||
assert_eq!(population_tier(0), 0);
|
||||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||||
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
|
||||
}
|
||||
|
||||
@@ -304,41 +307,81 @@ mod tests {
|
||||
6,
|
||||
7,
|
||||
);
|
||||
let transit = mix.districts.iter().filter(|d| matches!(d, DistrictType::Transit)).count();
|
||||
let commercial = mix.districts.iter().filter(|d| matches!(d, DistrictType::Commercial)).count();
|
||||
let residential = mix.districts.iter().filter(|d| matches!(d, DistrictType::Residential)).count();
|
||||
let transit = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Transit))
|
||||
.count();
|
||||
let commercial = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Commercial))
|
||||
.count();
|
||||
let residential = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Residential))
|
||||
.count();
|
||||
assert!(transit >= 1, "transit guarantee not met: {transit}");
|
||||
assert!(commercial >= 1, "commercial guarantee not met: {commercial}");
|
||||
assert!(residential >= 2, "residential guarantee not met: {residential}");
|
||||
assert!(
|
||||
commercial >= 1,
|
||||
"commercial guarantee not met: {commercial}"
|
||||
);
|
||||
assert!(
|
||||
residential >= 2,
|
||||
"residential guarantee not met: {residential}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed() {
|
||||
let mix1 = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99);
|
||||
let mix2 = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Commission, 6, 99);
|
||||
let mix1 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
let mix2 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_archetypes_produce_different_mixes() {
|
||||
let mix_corp = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Corporate, 8, 42);
|
||||
let mix_pioneer = compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
let mix_corp = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Corporate,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
let mix_pioneer =
|
||||
compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(mix_corp.districts, mix_pioneer.districts,
|
||||
"Corporate and Pioneer archetypes should produce different district mixes");
|
||||
assert_ne!(
|
||||
mix_corp.districts, mix_pioneer.districts,
|
||||
"Corporate and Pioneer archetypes should produce different district mixes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(
|
||||
2_000_000,
|
||||
"military",
|
||||
&PoliticalArchetype::Military,
|
||||
8,
|
||||
10,
|
||||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||||
let admin = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Administrative))
|
||||
.count();
|
||||
assert!(
|
||||
admin >= 1,
|
||||
"military archetype should have Administrative districts"
|
||||
);
|
||||
let admin = mix.districts.iter().filter(|d| matches!(d, DistrictType::Administrative)).count();
|
||||
assert!(admin >= 1, "military archetype should have Administrative districts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,8 +395,15 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix.districts.iter().any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(present, "DistrictType {:?} never appeared in 50-district mix", dt);
|
||||
let present = mix
|
||||
.districts
|
||||
.iter()
|
||||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(
|
||||
present,
|
||||
"DistrictType {:?} never appeared in 50-district mix",
|
||||
dt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +397,7 @@ fn merge_small_basins(
|
||||
let n_basins = sizes.len();
|
||||
|
||||
// Stop if within target range and all basins are large enough.
|
||||
if n_basins <= max_count
|
||||
&& sizes.values().all(|&s| s as f64 / n as f64 >= min_frac)
|
||||
{
|
||||
if n_basins <= max_count && sizes.values().all(|&s| s as f64 / n as f64 >= min_frac) {
|
||||
break;
|
||||
}
|
||||
if n_basins <= min_count {
|
||||
@@ -407,10 +405,7 @@ fn merge_small_basins(
|
||||
}
|
||||
|
||||
// Find the smallest basin.
|
||||
let (&smallest_id, &smallest_size) = sizes
|
||||
.iter()
|
||||
.min_by_key(|(_, &s)| s)
|
||||
.unwrap();
|
||||
let (&smallest_id, &smallest_size) = sizes.iter().min_by_key(|(_, &s)| s).unwrap();
|
||||
|
||||
if n_basins <= max_count && smallest_size as f64 / n as f64 >= min_frac {
|
||||
break;
|
||||
@@ -533,10 +528,8 @@ fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec<DrainageBasin> {
|
||||
|
||||
// Sort boundary by angle from centroid for a coherent polygon.
|
||||
if !boundary.is_empty() {
|
||||
let cr = boundary.iter().map(|&(r, _)| r as f32).sum::<f32>()
|
||||
/ boundary.len() as f32;
|
||||
let cc = boundary.iter().map(|&(_, c)| c as f32).sum::<f32>()
|
||||
/ boundary.len() as f32;
|
||||
let cr = boundary.iter().map(|&(r, _)| r as f32).sum::<f32>() / boundary.len() as f32;
|
||||
let cc = boundary.iter().map(|&(_, c)| c as f32).sum::<f32>() / boundary.len() as f32;
|
||||
boundary.sort_by(|&(r1, c1), &(r2, c2)| {
|
||||
let a1 = (r1 as f32 - cr).atan2(c1 as f32 - cc);
|
||||
let a2 = (r2 as f32 - cr).atan2(c2 as f32 - cc);
|
||||
@@ -644,8 +637,7 @@ mod tests {
|
||||
let r1 = analyze(&elev, 64, 32, 0.3);
|
||||
let r2 = analyze(&elev, 64, 32, 0.3);
|
||||
assert_eq!(
|
||||
r1.river_network.river_cells,
|
||||
r2.river_network.river_cells,
|
||||
r1.river_network.river_cells, r2.river_network.river_cells,
|
||||
"River cells must be deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -55,7 +55,10 @@ pub enum GenWorkItem {
|
||||
/// Generate a Phase 1 DistrictSkeleton for this city.
|
||||
GenerateSkeleton { city_id: u64 },
|
||||
/// Pre-fill a chunk in an existing district.
|
||||
FillChunk { district_id: u64, block_pos: (u32, u32) },
|
||||
FillChunk {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
}
|
||||
|
||||
impl GenWorkItem {
|
||||
@@ -75,11 +78,21 @@ impl GenWorkItem {
|
||||
/// Sent back to the main thread when a work item finishes (D-206).
|
||||
#[derive(Debug)]
|
||||
pub enum GenCompletion {
|
||||
BodyAnalyzed { body_id: String },
|
||||
SkeletonGenerated { city_id: u64 },
|
||||
ChunkFilled { district_id: u64, block_pos: (u32, u32) },
|
||||
BodyAnalyzed {
|
||||
body_id: String,
|
||||
},
|
||||
SkeletonGenerated {
|
||||
city_id: u64,
|
||||
},
|
||||
ChunkFilled {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
/// Work item failed — body_id or city_id for logging.
|
||||
Failed { item: GenWorkItem, reason: String },
|
||||
Failed {
|
||||
item: GenWorkItem,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,11 +127,7 @@ pub struct GenerationQueue {
|
||||
|
||||
impl std::fmt::Debug for GenerationQueue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let pending_len = self
|
||||
.pending
|
||||
.lock()
|
||||
.map(|p| p.len())
|
||||
.unwrap_or(0);
|
||||
let pending_len = self.pending.lock().map(|p| p.len()).unwrap_or(0);
|
||||
f.debug_struct("GenerationQueue")
|
||||
.field("pending_count", &pending_len)
|
||||
.finish()
|
||||
@@ -168,9 +177,7 @@ impl GenerationQueue {
|
||||
drop(in_flight);
|
||||
// Check pending list.
|
||||
let pending = self.pending.lock().unwrap();
|
||||
if pending.iter().any(|q| {
|
||||
q.item.body_id() == Some(body_id)
|
||||
}) {
|
||||
if pending.iter().any(|q| q.item.body_id() == Some(body_id)) {
|
||||
return;
|
||||
}
|
||||
drop(pending);
|
||||
@@ -216,10 +223,7 @@ impl GenerationQueue {
|
||||
|
||||
// Mark body as in-flight.
|
||||
if let Some(body_id) = item.body_id() {
|
||||
self.in_flight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(body_id.to_string());
|
||||
self.in_flight.lock().unwrap().insert(body_id.to_string());
|
||||
}
|
||||
|
||||
let tx = self.completion_tx.clone();
|
||||
@@ -261,18 +265,19 @@ impl Default for GenerationQueue {
|
||||
/// immediate success to allow the queue infrastructure to be tested independently.
|
||||
fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
match item {
|
||||
GenWorkItem::AnalyzeBody { body_id } => {
|
||||
GenCompletion::BodyAnalyzed { body_id: body_id.clone() }
|
||||
}
|
||||
GenWorkItem::AnalyzeBody { body_id } => GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
},
|
||||
GenWorkItem::GenerateSkeleton { city_id } => {
|
||||
GenCompletion::SkeletonGenerated { city_id: *city_id }
|
||||
}
|
||||
GenWorkItem::FillChunk { district_id, block_pos } => {
|
||||
GenCompletion::ChunkFilled {
|
||||
district_id: *district_id,
|
||||
block_pos: *block_pos,
|
||||
}
|
||||
}
|
||||
GenWorkItem::FillChunk {
|
||||
district_id,
|
||||
block_pos,
|
||||
} => GenCompletion::ChunkFilled {
|
||||
district_id: *district_id,
|
||||
block_pos: *block_pos,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +298,9 @@ mod tests {
|
||||
fn submit_and_drain() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody { body_id: "TestBody".to_string() },
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "TestBody".to_string(),
|
||||
},
|
||||
GenPriority::Medium,
|
||||
);
|
||||
// Give Rayon time to complete the (stub) task.
|
||||
@@ -311,11 +318,15 @@ mod tests {
|
||||
let q = make_queue();
|
||||
// Submit the same body twice before it can complete.
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody { body_id: "Dup".to_string() },
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody { body_id: "Dup".to_string() },
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
@@ -329,9 +340,18 @@ mod tests {
|
||||
// Submit three items rapidly; Immediate should be dispatched first.
|
||||
let q = make_queue();
|
||||
// Using GenerateSkeleton (no dedup logic) to test ordering directly.
|
||||
q.submit(GenWorkItem::GenerateSkeleton { city_id: 1 }, GenPriority::Low);
|
||||
q.submit(GenWorkItem::GenerateSkeleton { city_id: 2 }, GenPriority::Immediate);
|
||||
q.submit(GenWorkItem::GenerateSkeleton { city_id: 3 }, GenPriority::Medium);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 1 },
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 2 },
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 3 },
|
||||
GenPriority::Medium,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let completions = q.drain_completions();
|
||||
assert_eq!(completions.len(), 3);
|
||||
@@ -348,7 +368,10 @@ mod tests {
|
||||
fn pending_count_decreases_after_completion() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::FillChunk { district_id: 99, block_pos: (0, 0) },
|
||||
GenWorkItem::FillChunk {
|
||||
district_id: 99,
|
||||
block_pos: (0, 0),
|
||||
},
|
||||
GenPriority::High,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
@@ -139,9 +139,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) {
|
||||
let floats: Vec<f32> = (0..(w * h))
|
||||
.map(|i| i as f32 / (w * h) as f32)
|
||||
.collect();
|
||||
let floats: Vec<f32> = (0..(w * h)).map(|i| i as f32 / (w * h) as f32).collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&floats);
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level)
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
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,
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType,
|
||||
CityGenerationContext,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,8 +75,7 @@ pub fn generate_skeleton(
|
||||
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];
|
||||
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 {
|
||||
@@ -92,7 +90,10 @@ pub fn generate_skeleton(
|
||||
// ── 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()
|
||||
let primary_district_type = mix
|
||||
.districts
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(DistrictType::MixedUse);
|
||||
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
|
||||
|
||||
@@ -190,10 +191,18 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C
|
||||
|
||||
match world_tier {
|
||||
WorldTier::Epicenter | WorldTier::Regional => {
|
||||
if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate }
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Full
|
||||
} else {
|
||||
ComplexityTier::Moderate
|
||||
}
|
||||
}
|
||||
WorldTier::Backwater | WorldTier::Passage => {
|
||||
if pop_tier >= 1 { ComplexityTier::Moderate } else { ComplexityTier::Minimal }
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Moderate
|
||||
} else {
|
||||
ComplexityTier::Minimal
|
||||
}
|
||||
}
|
||||
WorldTier::Waypoint => ComplexityTier::Minimal,
|
||||
}
|
||||
@@ -245,7 +254,7 @@ fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4]
|
||||
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.
|
||||
// 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 {
|
||||
@@ -256,9 +265,7 @@ fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4]
|
||||
}
|
||||
|
||||
// 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())
|
||||
})
|
||||
core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,9 +318,9 @@ fn build_block_grid(
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
reservation,
|
||||
chunk_layout: String::new(), // stub
|
||||
chunk_layout: String::new(), // stub
|
||||
hosted_sites: Vec::new(),
|
||||
era: String::new(), // stub
|
||||
era: String::new(), // stub
|
||||
era_modifications: Vec::new(),
|
||||
era_cause: None,
|
||||
density_pct: density,
|
||||
@@ -349,7 +356,11 @@ fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
///
|
||||
/// 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> {
|
||||
fn derive_reservations(
|
||||
pop_tier: u8,
|
||||
economic_role: &str,
|
||||
_seed: u64,
|
||||
) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Park: large cities need open space.
|
||||
@@ -398,11 +409,14 @@ struct SkeletonLcg {
|
||||
impl SkeletonLcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Mix seed to avoid degenerate state at 0.
|
||||
Self { state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15) }
|
||||
Self {
|
||||
state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self.state
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
@@ -516,7 +530,9 @@ mod tests {
|
||||
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()
|
||||
let has_park = sk
|
||||
.reservations
|
||||
.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Park));
|
||||
assert!(has_park);
|
||||
}
|
||||
@@ -526,7 +542,9 @@ mod tests {
|
||||
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()
|
||||
let has_terminal = sk
|
||||
.reservations
|
||||
.iter()
|
||||
.any(|r| matches!(r.function, ReservationFunction::Terminal));
|
||||
assert!(has_terminal);
|
||||
}
|
||||
@@ -539,7 +557,10 @@ mod tests {
|
||||
// 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");
|
||||
assert!(
|
||||
b.reservation.is_some(),
|
||||
"block ({row},{col}) should be reserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +574,10 @@ mod tests {
|
||||
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.blocks[row][col].density_pct,
|
||||
sk2.blocks[row][col].density_pct
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
|
||||
|
||||
@@ -147,8 +147,11 @@ mod tests {
|
||||
fn era_floor_decay_enforces_cracked_minimum() {
|
||||
// Prosperous district in an EconomicDisruption-era block — still Cracked.
|
||||
let cond = tile_condition(0.90, Some(&EraCause::EconomicDisruption));
|
||||
assert_eq!(cond, TileCondition::Cracked,
|
||||
"EconomicDisruption floor must prevent Intact/Worn");
|
||||
assert_eq!(
|
||||
cond,
|
||||
TileCondition::Cracked,
|
||||
"EconomicDisruption floor must prevent Intact/Worn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -162,8 +165,11 @@ mod tests {
|
||||
fn era_floor_does_not_improve_condition() {
|
||||
// EconomicDisruption floor = Cracked; Broken score stays Broken.
|
||||
let cond = tile_condition(0.10, Some(&EraCause::EconomicDisruption));
|
||||
assert_eq!(cond, TileCondition::Broken,
|
||||
"Era floor must not improve condition below score-derived value");
|
||||
assert_eq!(
|
||||
cond,
|
||||
TileCondition::Broken,
|
||||
"Era floor must not improve condition below score-derived value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -144,9 +144,8 @@ fn collect_names(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
// Star systems — include both system_name and proper_name as separate patterns
|
||||
// so "Van Maanen's Star" and "GJ 35" both trigger if used in dialogue.
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT system_id, system_name, proper_name FROM star_systems",
|
||||
)?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT system_id, system_name, proper_name FROM star_systems")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
|
||||
Reference in New Issue
Block a user