//! 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 serde::{Deserialize, Serialize}; use crate::seed::Rng; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] 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 { 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) } pub(crate) 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 } } /// The phrase with "a" or "an" in front, by its first letter: "an ice /// world", "a desert world". pub fn a(phrase: &str) -> String { let article = if phrase.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" }; format!("{article} {phrase}") } 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)); } } }