diff --git a/crates/citygen/src/lib.rs b/crates/citygen/src/lib.rs index 8d3ed14..babebff 100644 --- a/crates/citygen/src/lib.rs +++ b/crates/citygen/src/lib.rs @@ -4,7 +4,9 @@ pub mod geom; pub mod ids; pub mod net; pub mod params; +pub mod rng; pub mod tile; +pub mod validate; pub mod world; pub use geom::{Dir, Grid, Pos, Rect, Size}; @@ -12,4 +14,5 @@ pub use ids::*; pub use net::*; pub use params::*; pub use tile::*; +pub use validate::*; pub use world::*; diff --git a/crates/citygen/src/params.rs b/crates/citygen/src/params.rs index 680ff9c..886c367 100644 --- a/crates/citygen/src/params.rs +++ b/crates/citygen/src/params.rs @@ -1,8 +1,23 @@ //! Generation parameters. use crate::geom::Size; +use crate::validate::Violation; +use crate::world::StratumKind; use serde::{Deserialize, Serialize}; +/// Everything that can go wrong while generating a city. +#[derive(Debug, thiserror::Error)] +pub enum GenError { + #[error("invalid parameter `{field}`: {reason}")] + InvalidParams { field: &'static str, reason: String }, + #[error("could not connect stratum {stratum:?} after repair rounds")] + Unconnected { stratum: StratumKind }, + #[error("flavor pack error: {0}")] + Flavor(String), + #[error("generated city violates invariants: {0:?}")] + Invalid(Vec), +} + #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub enum SizePreset { Small, @@ -74,3 +89,115 @@ impl Default for GenParams { } } } + +impl GenParams { + pub fn with_seed(mut self, seed: u64) -> Self { + self.seed = seed; + self + } + + pub fn validate(&self) -> Result<(), GenError> { + let bad = |field: &'static str, reason: String| GenError::InvalidParams { field, reason }; + let Size { w, h } = self.size.size(); + if !(80..=1024).contains(&w) || !(40..=512).contains(&h) { + return Err(bad( + "size", + format!("{w}x{h} is outside 80..=1024 by 40..=512"), + )); + } + if !(4..=12).contains(&self.districts) { + return Err(bad( + "districts", + format!("{} is outside 4..=12", self.districts), + )); + } + if (w as u64) * (h as u64) < self.districts as u64 * 1000 { + return Err(bad( + "districts", + format!( + "too many districts for map size ({} districts need at least {} tiles, map has {})", + self.districts, + self.districts as u64 * 1000, + w as u64 * h as u64 + ), + )); + } + if !(0.0..=1.0).contains(&self.density) { + return Err(bad("density", format!("{} is outside 0..=1", self.density))); + } + if !(-1.0..=1.0).contains(&self.wealth_skew) { + return Err(bad( + "wealth_skew", + format!("{} is outside -1..=1", self.wealth_skew), + )); + } + if !(0.0..=1.0).contains(&self.decay) { + return Err(bad("decay", format!("{} is outside 0..=1", self.decay))); + } + if self.flavor.trim().is_empty() { + return Err(bad("flavor", "flavor pack name is empty".into())); + } + Ok(()) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_rejects_bad_params() { + let bad = GenParams { + districts: 13, + ..Default::default() + }; + assert!(matches!( + bad.validate(), + Err(GenError::InvalidParams { + field: "districts", + .. + }) + )); + let bad = GenParams { + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 8, + ..Default::default() + }; + assert!(matches!( + bad.validate(), + Err(GenError::InvalidParams { + field: "districts", + .. + }) + )); + let bad = GenParams { + size: SizePreset::Custom { w: 40, h: 40 }, + ..Default::default() + }; + assert!(matches!( + bad.validate(), + Err(GenError::InvalidParams { field: "size", .. }) + )); + let bad = GenParams { + density: 1.5, + ..Default::default() + }; + assert!(matches!( + bad.validate(), + Err(GenError::InvalidParams { + field: "density", + .. + }) + )); + assert!(GenParams::default().validate().is_ok()); + assert!( + GenParams { + size: SizePreset::Small, + districts: 12, + ..Default::default() + } + .validate() + .is_ok() + ); + assert_eq!(GenParams::default().with_seed(9).seed, 9); + } +} diff --git a/crates/citygen/src/rng.rs b/crates/citygen/src/rng.rs new file mode 100644 index 0000000..e3f0e55 --- /dev/null +++ b/crates/citygen/src/rng.rs @@ -0,0 +1,72 @@ +//! Seeded randomness: per-stage RNG derivation and stable per-tile hashes. + +use crate::geom::Pos; +use crate::world::StratumKind; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +/// SplitMix-style mixer; stable across platforms. +pub fn hash64(mut a: u64, b: u64) -> u64 { + a ^= b.wrapping_mul(0x9E37_79B9_7F4A_7C15); + a = (a ^ (a >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + a = (a ^ (a >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + a ^ (a >> 31) +} + +/// An RNG private to one generation stage: changing another stage never reshuffles it. +pub fn stage_rng(seed: u64, stage: u8) -> ChaCha8Rng { + ChaCha8Rng::seed_from_u64(hash64(seed, 0xC17_u64 + stage as u64 * 0x1000)) +} + +/// Deterministic 8-bit variant for a tile position on a stratum. +pub fn tile_hash(seed: u64, k: StratumKind, p: Pos) -> u8 { + let packed = ((p.x as u32 as u64) << 32) | p.y as u32 as u64; + (hash64(hash64(seed, k.index() as u64), packed) >> 56) as u8 +} + +pub fn chance(rng: &mut impl Rng, p: f32) -> bool { + p > 0.0 && rng.random::() < p +} + +/// Pick by integer weight; a zero total falls back to the last item. +pub fn pick_weighted<'a, T>(rng: &mut impl Rng, items: &'a [(T, u32)]) -> &'a T { + let total: u32 = items.iter().map(|(_, w)| *w).sum(); + let mut roll = rng.random_range(0..total.max(1)); + for (item, w) in items { + if roll < *w { + return item; + } + roll -= *w; + } + &items[items.len() - 1].0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geom::Pos; + use crate::world::StratumKind; + use rand::Rng; + + #[test] + fn rng_is_deterministic_and_stage_isolated() { + let a: u64 = stage_rng(42, 3).random(); + let b: u64 = stage_rng(42, 3).random(); + let c: u64 = stage_rng(42, 4).random(); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!( + tile_hash(1, StratumKind::Street, Pos::new(5, 6)), + tile_hash(1, StratumKind::Street, Pos::new(5, 6)) + ); + assert_ne!( + tile_hash(1, StratumKind::Street, Pos::new(5, 6)), + tile_hash(1, StratumKind::Skyways, Pos::new(5, 6)) + ); + let mut r = stage_rng(1, 1); + let picked = pick_weighted(&mut r, &[("a", 0), ("b", 5)]); + assert_eq!(*picked, "b"); + assert!(!chance(&mut r, 0.0)); + assert!(chance(&mut r, 1.0)); + } +} diff --git a/crates/citygen/src/validate.rs b/crates/citygen/src/validate.rs new file mode 100644 index 0000000..3f70a45 --- /dev/null +++ b/crates/citygen/src/validate.rs @@ -0,0 +1,14 @@ +//! City invariants. + +use crate::world::City; + +/// A broken invariant found by [`City::validate`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Violation(pub String); + +impl City { + /// Checks the invariants every finished city must satisfy. + pub fn validate(&self) -> Result<(), Vec> { + Ok(()) + } +}