Skip to main content

proof_engine/procedural/
names.rs

1//! Procedural name generation — syllable chains and markov-like construction.
2//!
3//! Generates phonetically consistent names for creatures, NPCs, locations, and items.
4//! Each `NameStyle` has curated syllable tables. Names are built by combining
5//! prefixes, middles, and suffixes using a seeded RNG for reproducibility.
6
7use super::Rng;
8
9// ── NameStyle ─────────────────────────────────────────────────────────────────
10
11/// Style/culture of generated names.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NameStyle {
14    /// Dark, ominous names (undead, demons).
15    Dark,
16    /// Elvish-inspired (long, flowing).
17    Elvish,
18    /// Orcish (harsh, guttural).
19    Orcish,
20    /// Arcane (mystical, esoteric).
21    Arcane,
22    /// Draconic (ancient, powerful).
23    Draconic,
24    /// Void (strange, alien).
25    Void,
26    /// Place names (towns, dungeons).
27    Place,
28    /// Human common names.
29    Human,
30}
31
32// ── Syllable tables ───────────────────────────────────────────────────────────
33
34fn dark_prefixes()  -> &'static [&'static str] { &["Mor","Mal","Kra","Vor","Zar","Dra","Sha","Gul","Neth","Bal","Tor","Khal","Vex","Crul","Dusk","Grim","Nox"] }
35fn dark_middles()   -> &'static [&'static str] { &["ath","ul","ash","ak","ek","on","az","eth","ix","og","ur","an","ix","el","or"] }
36fn dark_suffixes()  -> &'static [&'static str] { &["us","ak","on","ur","ax","ix","oth","eth","ar","as","en","ul","or","ath"] }
37
38fn elvish_prefixes() -> &'static [&'static str] { &["Aer","Cal","Syl","Elan","Mir","Tal","Ael","Ith","Sel","Ari","Fin","Gil","Lir","Mel","Nel","Ori"] }
39fn elvish_middles()  -> &'static [&'static str] { &["an","aer","iel","ion","ias","iel","ian","ael","ial","uer","ien","ele","oth","ill","ir"] }
40fn elvish_suffixes() -> &'static [&'static str] { &["iel","ias","ion","ual","ael","ial","uen","iel","ean","ian","iel","ias","ier","ael"] }
41
42fn orcish_prefixes() -> &'static [&'static str] { &["Grak","Urg","Mag","Brul","Thog","Vrak","Krug","Gash","Ugreth","Marg","Bruk","Thrak"] }
43fn orcish_middles()  -> &'static [&'static str] { &["ak","ug","ag","ok","ut","rag","ash","ul","grak","nak","krul","gur"] }
44fn orcish_suffixes() -> &'static [&'static str] { &["nak","gash","rug","ak","ug","og","ul","ash","rok","krul","gut","mak"] }
45
46fn arcane_prefixes() -> &'static [&'static str] { &["Aex","Zyr","Vael","Xan","Qaer","Zyth","Aex","Ixir","Myzt","Pyrr","Zel","Xyr"] }
47fn arcane_middles()  -> &'static [&'static str] { &["ael","yth","yx","ire","ast","yrr","ael","ix","an","eth","yx","oth"] }
48fn arcane_suffixes() -> &'static [&'static str] { &["ix","aex","yth","ael","yx","ire","oth","ast","yrr","us","eth","ax"] }
49
50fn draconic_prefixes() -> &'static [&'static str] { &["Dra","Vrax","Thur","Kyr","Zur","Aur","Syr","Vor","Xar","Rha","Dur","Asr"] }
51fn draconic_middles()  -> &'static [&'static str] { &["ak","ul","ath","ix","on","ur","eth","or","ax","an","ith","us"] }
52fn draconic_suffixes() -> &'static [&'static str] { &["ix","ax","us","ath","on","ur","ix","eth","or","an","ith","ax"] }
53
54fn void_prefixes()   -> &'static [&'static str] { &["Zzz","Vhx","Yth","Xhl","Zhyr","Vlt","Kthx","Nyth","Pzr","Qxl","Zyx","Vlr"] }
55fn void_middles()    -> &'static [&'static str] { &["yx","hz","zyr","xn","th","rx","yz","xr","zx","hz","yr","xz"] }
56fn void_suffixes()   -> &'static [&'static str] { &["xyr","zth","vrx","yx","hz","xn","zyr","th","rx","yz","xr","vx"] }
57
58fn place_prefixes()  -> &'static [&'static str] { &["Stone","Dark","Iron","Grim","Shadow","Blood","Frost","Ash","Black","Bone","Crypt","Death","Doom"] }
59fn place_middles()   -> &'static [&'static str] { &["haven","hold","moor","gate","ridge","peak","forge","keep","vale","fell","fen","mere"] }
60fn place_suffixes()  -> &'static [&'static str] { &["heim","moor","fell","fen","vale","keep","hold","croft","wick","ford","ton","burg"] }
61
62fn human_prefixes()  -> &'static [&'static str] { &["Ald","Bar","Cal","Dan","Ed","Fred","Gar","Hal","Ing","Jon","Karl","Leo","Mar","Nor","Osw"] }
63fn human_middles()   -> &'static [&'static str] { &["ric","win","ald","bert","ward","mund","gar","helm","ulf","frid","her","wig","rath","run"] }
64fn human_suffixes()  -> &'static [&'static str] { &["son","sen","man","ard","ert","olf","ric","and","ley","ford","ton","ham","worth"] }
65
66// ── Title words ───────────────────────────────────────────────────────────────
67
68const DARK_TITLES:    &[&str] = &["the Cursed","the Defiler","Shadowbane","Deathmarch","the Undying","Soul Reaver","the Ancient"];
69const ORCISH_TITLES:  &[&str] = &["Skull Crusher","Ironhide","Bonecleaver","Warlord","Bloodaxe","the Mighty","the Feared"];
70const ARCANE_TITLES:  &[&str] = &["the Wise","Spellweaver","Runemaster","the Arcane","of the Inner Eye","the Eternal"];
71const PLACE_ADJECTIVES: &[&str] = &["Ancient","Cursed","Forsaken","Eternal","Ruined","Shadowed","Lost","Forgotten","Sunken"];
72
73// ── NameGenerator ─────────────────────────────────────────────────────────────
74
75/// Generates procedural names from syllable tables.
76#[derive(Clone, Debug)]
77pub struct NameGenerator {
78    pub style: NameStyle,
79}
80
81impl NameGenerator {
82    pub fn new(style: NameStyle) -> Self { Self { style } }
83
84    /// Generate a single name with `rng`.
85    pub fn generate(&self, rng: &mut Rng) -> String {
86        self.generate_with_title(rng, false)
87    }
88
89    /// Generate a name, optionally with an appended title/epithet.
90    pub fn generate_with_title(&self, rng: &mut Rng, add_title: bool) -> String {
91        let name = match self.style {
92            NameStyle::Dark     => self.build(rng, dark_prefixes(), dark_middles(), dark_suffixes()),
93            NameStyle::Elvish   => self.build(rng, elvish_prefixes(), elvish_middles(), elvish_suffixes()),
94            NameStyle::Orcish   => self.build(rng, orcish_prefixes(), orcish_middles(), orcish_suffixes()),
95            NameStyle::Arcane   => self.build(rng, arcane_prefixes(), arcane_middles(), arcane_suffixes()),
96            NameStyle::Draconic => self.build(rng, draconic_prefixes(), draconic_middles(), draconic_suffixes()),
97            NameStyle::Void     => self.build_void(rng),
98            NameStyle::Place    => self.build_place(rng),
99            NameStyle::Human    => self.build(rng, human_prefixes(), human_middles(), human_suffixes()),
100        };
101
102        if add_title {
103            let titles: &[&str] = match self.style {
104                NameStyle::Dark   | NameStyle::Draconic => DARK_TITLES,
105                NameStyle::Orcish                        => ORCISH_TITLES,
106                NameStyle::Arcane | NameStyle::Elvish    => ARCANE_TITLES,
107                _                                        => DARK_TITLES,
108            };
109            if let Some(&title) = rng.pick(titles) {
110                if rng.chance(0.35) {
111                    return format!("{name} {title}");
112                }
113            }
114        }
115        name
116    }
117
118    fn build(&self, rng: &mut Rng, pre: &[&str], mid: &[&str], suf: &[&str]) -> String {
119        let p = rng.pick(pre).copied().unwrap_or("Ka");
120        let s = rng.pick(suf).copied().unwrap_or("ar");
121
122        if rng.chance(0.6) || mid.is_empty() {
123            // 2-part name
124            capitalize(&format!("{p}{s}"))
125        } else {
126            let m = rng.pick(mid).copied().unwrap_or("an");
127            capitalize(&format!("{p}{m}{s}"))
128        }
129    }
130
131    fn build_void(&self, rng: &mut Rng) -> String {
132        // Void names are weirder: sometimes just consonants
133        let p = rng.pick(void_prefixes()).copied().unwrap_or("Zyx");
134        let s = rng.pick(void_suffixes()).copied().unwrap_or("xyr");
135        if rng.chance(0.4) {
136            let m = rng.pick(void_middles()).copied().unwrap_or("hz");
137            format!("{p}{m}{s}")
138        } else {
139            format!("{p}{s}")
140        }
141    }
142
143    fn build_place(&self, rng: &mut Rng) -> String {
144        let adj = if rng.chance(0.4) {
145            rng.pick(PLACE_ADJECTIVES).copied().unwrap_or("")
146        } else { "" };
147        let p = rng.pick(place_prefixes()).copied().unwrap_or("Dark");
148        let m = rng.pick(place_middles()).copied().unwrap_or("keep");
149        let s = if rng.chance(0.4) {
150            rng.pick(place_suffixes()).copied().unwrap_or("")
151        } else { "" };
152        let base = if s.is_empty() {
153            format!("{p}{m}")
154        } else {
155            format!("{p}{m}{s}")
156        };
157        if adj.is_empty() { base } else { format!("{adj} {base}") }
158    }
159
160    /// Generate a list of `n` unique names.
161    pub fn generate_n(&self, rng: &mut Rng, n: usize) -> Vec<String> {
162        let mut names = std::collections::HashSet::new();
163        let mut result = Vec::with_capacity(n);
164        let mut attempts = 0usize;
165        while result.len() < n && attempts < n * 10 {
166            let name = self.generate(rng);
167            if names.insert(name.clone()) {
168                result.push(name);
169            }
170            attempts += 1;
171        }
172        result
173    }
174}
175
176fn capitalize(s: &str) -> String {
177    let mut c = s.chars();
178    match c.next() {
179        None    => String::new(),
180        Some(f) => f.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(),
181    }
182}
183
184// ── Name table ────────────────────────────────────────────────────────────────
185
186/// A seeded name table that pre-generates and caches names.
187pub struct NameTable {
188    names:   Vec<String>,
189    cursor:  usize,
190}
191
192impl NameTable {
193    pub fn generate(style: NameStyle, count: usize, seed: u64) -> Self {
194        let mut rng = Rng::new(seed);
195        let gen = NameGenerator::new(style);
196        let names = gen.generate_n(&mut rng, count);
197        Self { names, cursor: 0 }
198    }
199
200    /// Get the next name from the table (wraps around).
201    pub fn next(&mut self) -> &str {
202        if self.names.is_empty() { return "Unknown"; }
203        let name = &self.names[self.cursor % self.names.len()];
204        self.cursor += 1;
205        name
206    }
207
208    pub fn peek(&self, i: usize) -> Option<&str> {
209        self.names.get(i % self.names.len().max(1)).map(|s| s.as_str())
210    }
211
212    pub fn len(&self) -> usize { self.names.len() }
213}
214
215// ── Tests ─────────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn dark_name_non_empty() {
223        let mut rng = Rng::new(42);
224        let gen = NameGenerator::new(NameStyle::Dark);
225        let name = gen.generate(&mut rng);
226        assert!(!name.is_empty());
227        assert!(name.chars().next().unwrap().is_uppercase());
228    }
229
230    #[test]
231    fn all_styles_produce_names() {
232        let mut rng = Rng::new(99);
233        for style in &[NameStyle::Dark, NameStyle::Elvish, NameStyle::Orcish,
234                        NameStyle::Arcane, NameStyle::Draconic, NameStyle::Void,
235                        NameStyle::Place, NameStyle::Human] {
236            let gen = NameGenerator::new(*style);
237            let name = gen.generate(&mut rng);
238            assert!(!name.is_empty(), "Empty name for style {:?}", style);
239        }
240    }
241
242    #[test]
243    fn generate_n_returns_n_unique() {
244        let mut rng = Rng::new(12345);
245        let gen = NameGenerator::new(NameStyle::Human);
246        let names = gen.generate_n(&mut rng, 20);
247        // Allow some duplicates in edge cases but should be mostly unique
248        assert!(names.len() >= 10);
249    }
250
251    #[test]
252    fn name_table_wraps() {
253        let mut table = NameTable::generate(NameStyle::Dark, 5, 777);
254        let first = table.next().to_string();
255        for _ in 0..4 { table.next(); }
256        let again = table.next().to_string();
257        assert_eq!(first, again);
258    }
259}