summaryrefslogtreecommitdiff
path: root/src/names
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/names
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/names')
-rw-r--r--src/names/artifact.rs69
-rw-r--r--src/names/mod.rs319
2 files changed, 388 insertions, 0 deletions
diff --git a/src/names/artifact.rs b/src/names/artifact.rs
new file mode 100644
index 0000000..af24b3d
--- /dev/null
+++ b/src/names/artifact.rs
@@ -0,0 +1,69 @@
+//! Artifact names: what a thing looks like when you find it, and what it's
+//! called once a scholar has told you what it is.
+
+use rand::RngExt;
+
+use super::{Culture, pick};
+use crate::seed::Rng;
+
+#[derive(Clone, Debug)]
+pub struct Artifact {
+ /// What you see when you find it: "a humming ochre lattice".
+ pub unidentified: String,
+ /// Its true name: "the Lacquered Orrery of Teshemond".
+ pub true_name: String,
+}
+
+pub fn artifact(rng: &mut Rng) -> Artifact {
+ let look = format!("{} {} {}", pick(QUALITIES, rng), pick(COLOURS, rng), pick(SHAPES, rng));
+ let article = if look.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" };
+ let unidentified = format!("{article} {look}");
+
+ let object = pick(OBJECTS, rng);
+ let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())];
+ let true_name = match rng.random_range(0..5) {
+ 0 => format!("the {} {object} of {}", pick(MATERIALS, rng), culture.word(rng)),
+ 1 => format!("the {object} of {}", culture.place(rng)),
+ 2 => format!("{}'s {object}", culture.word(rng)),
+ 3 => format!("the {} {object}", pick(EPITHETS, rng)),
+ _ => format!("the {object} {}", pick(CLAUSES, rng)),
+ };
+ Artifact { unidentified, true_name }
+}
+
+const QUALITIES: &[&str] = &[
+ "humming", "warm", "weightless", "faintly singing", "ice-cold", "flickering", "whispering",
+ "slowly turning", "perfectly still", "pulsing", "dust-caked", "oddly heavy", "tarnished",
+ "ticking", "breathing",
+];
+
+const COLOURS: &[&str] = &[
+ "ochre", "verdigris", "bone-white", "umber", "cerulean", "oxblood", "iridescent", "smoke-grey",
+ "jet-black", "saffron", "rust-red", "pearlescent", "cobalt",
+];
+
+const SHAPES: &[&str] = &[
+ "lattice", "spindle", "disc", "knot", "sphere", "prism", "coil", "shard", "ring", "cube",
+ "cylinder", "helix", "lens", "rod", "tessera",
+];
+
+const MATERIALS: &[&str] = &[
+ "Saltglass", "Black-Iron", "Lacquered", "Bone", "Star-Ivory", "Verdigris", "Voidsteel",
+ "Smoked-Glass", "Amber", "Obsidian", "Gilt", "Quicksilver",
+];
+
+const OBJECTS: &[&str] = &[
+ "Orrery", "Reliquary", "Astrolabe", "Censer", "Lattice", "Mask", "Crown", "Key", "Sigil",
+ "Codex", "Lens", "Spindle", "Compass", "Chalice", "Engine", "Bell", "Heart", "Mirror", "Loom",
+];
+
+const EPITHETS: &[&str] = &[
+ "Weeping", "Ninth", "Hollow", "Unspoken", "Last", "Sleeping", "Burning", "Patient", "Seventh",
+ "Silent", "Drowned", "Unfinished", "Forgotten", "Inverted",
+];
+
+const CLAUSES: &[&str] = &[
+ "That Remembers", "That Was Promised", "Which Does Not Turn", "That Sings at Dusk",
+ "That Counts the Dead", "That Opens Inward", "That Waits", "of Unmaking", "of Seven Silences",
+ "Beneath the Sun",
+];
diff --git a/src/names/mod.rs b/src/names/mod.rs
new file mode 100644
index 0000000..719455f
--- /dev/null
+++ b/src/names/mod.rs
@@ -0,0 +1,319 @@
+//! Name generation.
+//!
+//! Each culture has its own syllables, so names from one region sound related
+//! to each other and different from the next region's. A word is a start
+//! syllable, maybe some middles, and an end; templates such as `"{w} ul-{w}"`
+//! turn words into people and places.
+//!
+//! The lists are meant to be tuned by ear: edit them, then run
+//! `cargo run -- names --culture qaari -n 40` to hear the result.
+//!
+//! Within a culture, syllables are written so that every join works: in most
+//! cultures starts and middles end in a vowel and middles and ends begin with
+//! a consonant, so they always meet vowel-to-consonant. `pronounceable` throws
+//! out whatever still comes out badly.
+
+mod artifact;
+
+pub use artifact::{Artifact, artifact};
+
+use std::fmt::Write as _;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use crate::seed::Rng;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Culture {
+ Qaari,
+ Cant,
+ Houses,
+ Drift,
+}
+
+struct Phonology {
+ starts: &'static [&'static str],
+ middles: &'static [&'static str],
+ ends: &'static [&'static str],
+ /// How many middle syllables a word gets: anywhere from .0 to .1.
+ middles_per_word: (u32, u32),
+ /// Templates for people. `{w}` is a new word, `{n}` a number from 2 to
+ /// 99, `{r}` a Roman numeral. Repeat a template to make it more common.
+ people: &'static [&'static str],
+ /// Templates for places, filled in the same way.
+ places: &'static [&'static str],
+}
+
+const QAARI: Phonology = Phonology {
+ starts: &[
+ "qa", "kha", "sha", "za", "ta", "ha", "ya", "ma", "i", "u", "a", "su", "ze", "ru", "khe",
+ "qe", "na", "ba",
+ ],
+ middles: &[
+ "'a", "'i", "'u", "ssa", "ra", "lu", "shi", "the", "ka", "ma", "zi", "rru", "da", "hi",
+ ],
+ ends: &[
+ "run", "vel", "seem", "taq", "rim", "zar", "dul", "keth", "mah", "resh", "noun", "sir",
+ "qal", "riya", "'at", "'im", "n", "r", "sh", "dah", "zim",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w} {w}", "{w} ul-{w}", "{w} ul-{w}", "{w} of the {w} Erg"],
+ places: &["{w}", "{w}", "{w} {w}", "Erg {w}", "the {w} Wells", "{w} Hollow"],
+};
+
+// The Cant joins the other way round: starts end in a consonant, ends begin
+// with a vowel. That's where the hard stops come from.
+const CANT: Phonology = Phonology {
+ starts: &[
+ "kor", "vex", "drak", "tor", "brann", "ix", "zed", "kal", "grim", "stak", "vor", "dex",
+ "kryn", "thul", "garr", "oct", "rhun",
+ ],
+ middles: &["ax", "en", "ul", "ir", "ot", "ek"],
+ ends: &["ex", "on", "ax", "ik", "us", "ant", "ek", "ost", "ar", "ion", "um", "a", "o", "is"],
+ middles_per_word: (0, 1),
+ people: &["{w}-{n}", "{w}-{n}", "Cantor {w}-{n}", "{w} {w}", "{w} {r}", "Fabricant {w}"],
+ places: &["{w}-{n}", "{w} Spire", "Foundry {w}", "{w} {r}", "the {w} Engine"],
+};
+
+const HOUSES: Phonology = Phonology {
+ starts: &[
+ "va", "so", "i", "ca", "me", "au", "ve", "o", "se", "the", "ly", "ama", "eu", "ari", "ce",
+ "di", "lu",
+ ],
+ middles: &["ste", "ri", "la", "mo", "ce", "ve", "ra", "the", "li", "ssa", "da"],
+ ends: &[
+ "mond", "stre", "var", "rion", "nelle", "nard", "rine", "lis", "thane", "sande", "vere",
+ "lior", "dane", "rath", "ssandre", "mire",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w} {w}", "{w} {w}", "{w} of House {w}", "Archon {w} {w}", "{w} {w} the Younger"],
+ places: &["{w}", "{w}'s Reach", "{w} Prime", "New {w}", "{w} {w}", "the {w} Gardens"],
+};
+
+const DRIFT: Phonology = Phonology {
+ starts: &["nu", "o", "y", "ve", "li", "sa", "e", "mi", "no", "u", "ae", "ilo", "wa", "si"],
+ middles: &["va", "lo", "so", "ne", "lla", "sse", "ri", "wa", "le"],
+ ends: &[
+ "nn", "ss", "l", "lei", "ra", "vael", "th", "na", "ssa", "ren", "lue", "wyn", "s", "lith",
+ "mae",
+ ],
+ middles_per_word: (0, 1),
+ people: &["{w}", "{w}", "{w} {w}", "{w} of the {w}"],
+ places: &["{w}", "the {w} Drift", "{w} Anchorage", "{w} Deep"],
+};
+
+const ROMAN: &[&str] = &["II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"];
+
+impl Culture {
+ pub const ALL: [Culture; 4] = [Culture::Qaari, Culture::Cant, Culture::Houses, Culture::Drift];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Culture::Qaari => "the Qaari",
+ Culture::Cant => "the Cant",
+ Culture::Houses => "the High Houses",
+ Culture::Drift => "the Drift",
+ }
+ }
+
+ /// For describing things: "a Cant hauler", "a High House cutter".
+ pub fn adjective(self) -> &'static str {
+ match self {
+ Culture::Qaari => "Qaari",
+ Culture::Cant => "Cant",
+ Culture::Houses => "High House",
+ Culture::Drift => "Drift",
+ }
+ }
+
+ pub fn blurb(self) -> &'static str {
+ match self {
+ Culture::Qaari => "dune-sea clans: guttural, sun-cracked, apostrophes",
+ Culture::Cant => "derelict machine-cult: hard stops, serial numbers",
+ Culture::Houses => "old nobility: ornate, liquid, Latinate",
+ Culture::Drift => "void-born spacers: soft, breathy, doubled letters",
+ }
+ }
+
+ /// Accepts the short names the CLI uses: qaari, cant, houses, drift.
+ pub fn parse(s: &str) -> Option<Culture> {
+ match s.to_ascii_lowercase().as_str() {
+ "qaari" => Some(Culture::Qaari),
+ "cant" => Some(Culture::Cant),
+ "houses" => Some(Culture::Houses),
+ "drift" => Some(Culture::Drift),
+ _ => None,
+ }
+ }
+
+ fn phonology(self) -> &'static Phonology {
+ match self {
+ Culture::Qaari => &QAARI,
+ Culture::Cant => &CANT,
+ Culture::Houses => &HOUSES,
+ Culture::Drift => &DRIFT,
+ }
+ }
+
+ /// One capitalised word: "Khaseem", "Drakulon", "Ithane", "Nuvael".
+ pub fn word(self, rng: &mut Rng) -> String {
+ let mut candidate = String::new();
+ for _ in 0..100 {
+ candidate = self.syllables(rng).concat();
+ if pronounceable(&candidate) {
+ break;
+ }
+ }
+ capitalise(&candidate)
+ }
+
+ /// Picks a word's syllables, avoiding stutters like "the-the" or "qa-qal"
+ /// (a syllable that opens with the same two letters as the one before).
+ fn syllables(self, rng: &mut Rng) -> Vec<&'static str> {
+ let p = self.phonology();
+ let (min, max) = p.middles_per_word;
+ let middles = rng.random_range(min..=max) as usize;
+ let mut parts = vec![pick(p.starts, rng)];
+ for i in 0..=middles {
+ let list = if i < middles { p.middles } else { p.ends };
+ let prev = parts[parts.len() - 1];
+ let mut next = pick(list, rng);
+ for _ in 0..10 {
+ if prev.len() < 2 || !next.starts_with(&prev[..2]) {
+ break;
+ }
+ next = pick(list, rng);
+ }
+ parts.push(next);
+ }
+ parts
+ }
+
+ pub fn person(self, rng: &mut Rng) -> String {
+ self.expand(pick(self.phonology().people, rng), rng)
+ }
+
+ pub fn place(self, rng: &mut Rng) -> String {
+ self.expand(pick(self.phonology().places, rng), rng)
+ }
+
+ fn expand(self, template: &str, rng: &mut Rng) -> String {
+ let mut out = String::new();
+ let mut rest = template;
+ while let Some(open) = rest.find('{') {
+ out.push_str(&rest[..open]);
+ let close = open + rest[open..].find('}').expect("unclosed { in a name template");
+ match &rest[open + 1..close] {
+ "w" => out.push_str(&self.word(rng)),
+ "n" => write!(out, "{}", rng.random_range(2..100)).unwrap(),
+ "r" => out.push_str(pick(ROMAN, rng)),
+ slot => panic!("unknown slot {{{slot}}} in name template {template:?}"),
+ }
+ rest = &rest[close + 1..];
+ }
+ out.push_str(rest);
+ out
+ }
+}
+
+pub(crate) fn pick(list: &'static [&'static str], rng: &mut Rng) -> &'static str {
+ list.choose(rng).expect("name lists are never empty")
+}
+
+/// Rejects words that are too short, too long, or have clusters no one could
+/// say. The limits are by ear; loosen them if a culture wants harsher words.
+fn pronounceable(word: &str) -> bool {
+ let letters = word.chars().filter(|c| c.is_ascii_alphabetic()).count();
+ if !(3..=11).contains(&letters)
+ || word.starts_with('\'')
+ || word.ends_with('\'')
+ || word.matches('\'').count() > 1
+ {
+ return false;
+ }
+ let (mut vowels, mut consonants, mut same) = (0, 0, 0);
+ let mut prev = None;
+ for c in word.chars() {
+ same = if Some(c) == prev { same + 1 } else { 1 };
+ prev = Some(c);
+ match c {
+ '\'' => (vowels, consonants) = (0, 0),
+ 'a' | 'e' | 'i' | 'o' | 'u' | 'y' => (vowels, consonants) = (vowels + 1, 0),
+ _ => (vowels, consonants) = (0, consonants + 1),
+ }
+ if vowels > 2 || consonants > 3 || same > 2 {
+ return false;
+ }
+ }
+ true
+}
+
+fn capitalise(word: &str) -> String {
+ let mut chars = word.chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().chain(chars).collect(),
+ None => String::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::seed;
+
+ #[test]
+ fn same_seed_same_names() {
+ for culture in Culture::ALL {
+ let a: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect();
+ let b: Vec<_> = (0..20).map(|i| culture.person(&mut seed::rng(i))).collect();
+ assert_eq!(a, b);
+ }
+ }
+
+ #[test]
+ fn words_are_well_formed() {
+ for culture in Culture::ALL {
+ let mut rng = seed::rng(1);
+ for _ in 0..5000 {
+ let w = culture.word(&mut rng);
+ assert!(w.starts_with(|c: char| c.is_ascii_uppercase()), "{culture:?}: {w:?}");
+ assert!(pronounceable(&w.to_lowercase()), "{culture:?}: {w:?}");
+ }
+ }
+ }
+
+ /// Mostly checks that each syllable list joins up: if a culture's words
+ /// keep failing `pronounceable`, `word` falls back to unpronounceable ones.
+ #[test]
+ fn most_candidates_are_pronounceable() {
+ for culture in Culture::ALL {
+ let mut rng = seed::rng(2);
+ let mut ok = 0;
+ for _ in 0..1000 {
+ ok += usize::from(pronounceable(&culture.syllables(&mut rng).concat()));
+ }
+ assert!(ok > 700, "{culture:?}: only {ok}/1000 candidates were pronounceable");
+ }
+ }
+
+ #[test]
+ fn every_template_expands() {
+ let mut rng = seed::rng(3);
+ for culture in Culture::ALL {
+ let p = culture.phonology();
+ for template in p.people.iter().chain(p.places) {
+ let name = culture.expand(template, &mut rng);
+ assert!(!name.contains('{'), "{template:?} gave {name:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn parses_every_culture() {
+ for culture in Culture::ALL {
+ let short = culture.name().rsplit(' ').next().unwrap();
+ assert_eq!(Culture::parse(short), Some(culture));
+ }
+ }
+}