summaryrefslogtreecommitdiff
path: root/src/seed.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-10 06:50:08 -0800
committerskylar <me@casper>2026-11-10 06:50:08 -0800
commitd36c54d996f3c14ae811312dbbe621e12a39edde (patch)
tree1807134323479ac90bf69d28e410a7f70cb235a1 /src/seed.rs
Orrery: game loop, name generator and derelict dungeons
An ASCII sci-fi-fantasy game for the terminal, built on ratatui. - Game loop with a fixed 30 Hz tick and a stack of scenes; the kitty keyboard protocol is turned on where the terminal supports it, so later real-time scenes can tell when a key is held. - Stable hierarchical seeds: the whole universe derives from one u64, with tests pinning the exact outputs so old seeds keep working. - Name generator with four cultures, each with its own syllables and templates, plus unidentified/true names for artifacts. - Title screen with an animated orrery, and a name forge for browsing generated names. - Derelict ships to board: ship-shaped maps with an engine room, spine corridor, side rooms and bridge; symmetric shadowcasting sight; monsters that wake on sight and hunt; loot carried out through the airlock goes into the hold. - `orrery names` and `orrery ship` print the generators' output without the TUI, for tuning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src/seed.rs')
-rw-r--r--src/seed.rs93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/seed.rs b/src/seed.rs
new file mode 100644
index 0000000..b7491e7
--- /dev/null
+++ b/src/seed.rs
@@ -0,0 +1,93 @@
+//! Stable, hierarchical seeds.
+//!
+//! The universe is a pure function of one `u64`. Each thing derives its seed
+//! from its parent's plus a label and an index:
+//!
+//! ```text
+//! let system = seed::derive(universe, "system", 42);
+//! let wreck = seed::derive(system, "derelict", 3);
+//! ```
+//!
+//! so any one thing can be regenerated without generating everything before
+//! it, and a save file only needs the root seed plus what the player changed.
+//!
+//! Everything here must give the same answer forever: a change silently
+//! reshuffles every universe. That's why this doesn't use std's
+//! `DefaultHasher` (allowed to change between Rust releases) or rand's
+//! `seed_from_u64` (allowed to change between rand releases), and why the
+//! tests pin exact outputs.
+
+use rand::SeedableRng;
+use rand_chacha::ChaCha8Rng;
+
+/// The RNG for all procedural generation. ChaCha's output is specified and
+/// rand_chacha promises not to change it, unlike rand's `StdRng`.
+pub type Rng = ChaCha8Rng;
+
+/// SplitMix64's finalizer: scrambles every input bit into every output bit.
+const fn mix(mut z: u64) -> u64 {
+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ z ^ (z >> 31)
+}
+
+/// FNV-1a: a tiny hash for turning labels into numbers.
+const fn fnv1a(bytes: &[u8]) -> u64 {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut i = 0;
+ while i < bytes.len() {
+ hash ^= bytes[i] as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ i += 1;
+ }
+ hash
+}
+
+/// The seed of the `index`th `label` inside `parent`.
+pub const fn derive(parent: u64, label: &str, index: u64) -> u64 {
+ mix(mix(parent ^ fnv1a(label.as_bytes())).wrapping_add(mix(index ^ 0x9e37_79b9_7f4a_7c15)))
+}
+
+/// An RNG for generating whatever `seed` identifies.
+pub fn rng(seed: u64) -> Rng {
+ let mut bytes = [0u8; 32];
+ let mut state = seed;
+ for chunk in bytes.as_chunks_mut::<8>().0 {
+ state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ *chunk = mix(state).to_le_bytes();
+ }
+ Rng::from_seed(bytes)
+}
+
+/// A fresh universe seed for when the player doesn't pick one.
+pub fn from_clock() -> u64 {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |d| d.as_nanos() as u64);
+ // Keep seeds short enough to read aloud and retype.
+ mix(nanos ^ u64::from(std::process::id())) % 1_000_000_000
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rand::Rng as _;
+
+ /// If this fails, every existing universe just changed. Only update the
+ /// numbers if you mean to break old seeds and saves.
+ #[test]
+ fn outputs_never_change() {
+ assert_eq!(derive(0, "system", 0), PINNED_DERIVE);
+ assert_eq!(rng(0).next_u64(), PINNED_RNG);
+ }
+ const PINNED_DERIVE: u64 = 11_894_545_496_673_509_984;
+ const PINNED_RNG: u64 = 13_804_888_775_535_289_832;
+
+ #[test]
+ fn labels_and_indices_give_different_seeds() {
+ let a = derive(7, "system", 0);
+ assert_ne!(a, derive(7, "system", 1));
+ assert_ne!(a, derive(7, "derelict", 0));
+ assert_ne!(a, derive(8, "system", 0));
+ }
+}