diff options
Diffstat (limited to 'src/names')
| -rw-r--r-- | src/names/artifact.rs | 69 | ||||
| -rw-r--r-- | src/names/mod.rs | 319 |
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)); + } + } +} |