feat(citygen): params validation, GenError, seeded rng helpers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ pub mod geom;
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod params;
|
pub mod params;
|
||||||
|
pub mod rng;
|
||||||
pub mod tile;
|
pub mod tile;
|
||||||
|
pub mod validate;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|
||||||
pub use geom::{Dir, Grid, Pos, Rect, Size};
|
pub use geom::{Dir, Grid, Pos, Rect, Size};
|
||||||
@@ -12,4 +14,5 @@ pub use ids::*;
|
|||||||
pub use net::*;
|
pub use net::*;
|
||||||
pub use params::*;
|
pub use params::*;
|
||||||
pub use tile::*;
|
pub use tile::*;
|
||||||
|
pub use validate::*;
|
||||||
pub use world::*;
|
pub use world::*;
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
//! Generation parameters.
|
//! Generation parameters.
|
||||||
|
|
||||||
use crate::geom::Size;
|
use crate::geom::Size;
|
||||||
|
use crate::validate::Violation;
|
||||||
|
use crate::world::StratumKind;
|
||||||
use serde::{Deserialize, Serialize};
|
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<Violation>),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum SizePreset {
|
pub enum SizePreset {
|
||||||
Small,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
72
crates/citygen/src/rng.rs
Normal file
72
crates/citygen/src/rng.rs
Normal file
@@ -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::<f32>() < 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/citygen/src/validate.rs
Normal file
14
crates/citygen/src/validate.rs
Normal file
@@ -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<Violation>> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user