use crate::world::compile::polities_layer::PolitiesOutput;
#[derive(Debug, Clone, PartialEq)]
pub struct Culture {
pub polity: String,
pub ethos: String,
pub belief: String,
pub language_profile: String,
pub naming_sample: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CultureOutput {
pub cultures: Vec<Culture>,
}
fn h(a: u64, salt: u64) -> u64 {
a.wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503)).wrapping_add(0x9E37)
}
fn pick<'a>(items: &[&'a str], k: u64) -> &'a str {
items[(k % items.len() as u64) as usize]
}
fn biome_ethos(biome: &str) -> &'static str {
match biome {
b if b.contains("forest") || b == "taiga" => "woodland-reverent",
b if b.contains("desert") => "austere and hospitable",
b if b.contains("grassland") || b == "savanna" => "roaming and herd-proud",
b if b.contains("tropical") => "vivid and ceremonious",
b if b == "tundra" || b == "ice_cap" => "hardy and close-knit",
b if b == "mediterranean" => "mercantile and civic",
_ => "settled and pragmatic",
}
}
pub fn compile_culture(
pol: &PolitiesOutput,
capital_biomes: &[String],
declared: &[crate::world::types::CultureDef],
seed: u64,
) -> CultureOutput {
const TEMPER: &[&str] =
&["proud", "cautious", "curious", "devout", "stubborn", "generous", "secretive", "boisterous"];
const BELIEF: &[&str] = &[
"ancestor veneration",
"a sky-pantheon",
"one hidden god",
"nature spirits of river and stone",
"a cult of the seasons",
"reverence for the founding dead",
];
const ORDER: &[&str] = &["SVO", "SOV", "VSO"];
const MORPH: &[&str] = &["isolating", "agglutinative", "fusional"];
const SOUND: &[&str] = &["tonal", "guttural", "liquid and vowel-rich", "clipped and consonantal"];
const ONSET: &[&str] = &["Ka", "Ser", "Tho", "Vae", "Mor", "Ilu", "Bra", "Nen"];
const TAIL: &[&str] = &["ra", "eth", "is", "un", "ora", "ai", "el"];
let cultures = pol
.polities
.iter()
.enumerate()
.map(|(i, p)| {
let biome = capital_biomes.get(i).map(String::as_str).unwrap_or("");
let s = seed.wrapping_add((i as u64) << 8);
let mut c = Culture {
polity: p.name.clone(),
ethos: format!("{}, {}", biome_ethos(biome), pick(TEMPER, h(s, 1))),
belief: pick(BELIEF, h(s, 2)).to_string(),
language_profile: format!(
"{} · {} · {}",
pick(ORDER, h(s, 3)),
pick(MORPH, h(s, 4)),
pick(SOUND, h(s, 5))
),
naming_sample: format!("{}{}", pick(ONSET, h(s, 6)), pick(TAIL, h(s, 7))),
};
if let Some(d) = declared.iter().find(|d| d.nation.eq_ignore_ascii_case(&p.name)) {
if !d.ethos.trim().is_empty() {
c.ethos = d.ethos.clone();
}
if !d.belief.trim().is_empty() {
c.belief = d.belief.clone();
}
if !d.language.trim().is_empty() {
c.language_profile = d.language.clone();
}
}
c
})
.collect();
CultureOutput { cultures }
}
pub fn culture_style_name(culture: &Culture, seed: u64, index: usize) -> String {
const ONSET: &[&str] = &[
"Ka", "Ser", "Tho", "Vae", "Mor", "Ilu", "Bra", "Nen", "Kor", "Ael", "Dun", "Syl", "Tor", "Vel",
];
const TAIL: &[&str] =
&["ra", "eth", "is", "un", "ora", "ai", "el", "or", "yn", "ath", "ir", "une", "wen", "dar"];
let base = culture.polity.bytes().fold(seed ^ 0x5EED, |a, b| h(a, b as u64));
let on = |k: u64| ONSET[(h(base, k) as usize) % ONSET.len()];
let ta = |k: u64| TAIL[(h(base, 100 + k) as usize) % TAIL.len()];
let i = index as u64;
if h(base, 900 + i) % 3 == 0 {
format!("{}{}{}", on(i % 3), on((i + 1) % 3).to_lowercase(), ta(i % 3))
} else {
format!("{}{}", on(i % 3), ta((i + 1) % 3))
}
}
pub fn elaborate_role(base: &str, culture: &Culture, biome: &str) -> String {
let biome_word = biome.split('_').next().filter(|s| !s.is_empty()).unwrap_or("home");
let ethos_head = culture.ethos.split([',', ' ']).find(|s| !s.is_empty()).unwrap_or("");
let belief = culture.belief.trim();
match base {
"priest" => {
if belief.is_empty() {
"priest".into()
} else {
format!("keeper of {belief}")
}
}
"warrior" => {
if ethos_head.is_empty() {
"warrior".into()
} else {
format!("{ethos_head} warrior")
}
}
"farmer" => format!("{biome_word}-tiller"),
"fisher" => "netcaster".into(),
"herder" => format!("{biome_word}-herder"),
"woodcutter" => "forester".into(),
"smith" => "forge-master".into(),
"merchant" => {
if culture.ethos.contains("mercantile") {
"factor".into()
} else {
"trader".into()
}
}
"vintner" => "vine-keeper".into(),
other => other.to_string(),
}
}
pub fn lint_culture(
declared: &[crate::world::types::CultureDef],
pol: &PolitiesOutput,
capital_biomes: &[String],
) -> Vec<String> {
let mut w = Vec::new();
for d in declared {
match pol.polities.iter().position(|p| p.name.eq_ignore_ascii_case(&d.nation)) {
None => w.push(format!("culture: pinned to nation `{}`, which is not among the world's realms", d.nation)),
Some(i) => {
let biome = capital_biomes.get(i).map(String::as_str).unwrap_or("");
let e = d.ethos.to_lowercase();
let maritime = ["seafaring", "maritime", "naval", "coastal", "harbour", "harbor", "sea-"]
.iter()
.any(|k| e.contains(k));
let dry_inland = biome.contains("desert") || biome.contains("grassland") || biome == "savanna";
if maritime && dry_inland {
w.push(format!(
"culture `{}`: a seafaring ethos, but its capital sits in a dry inland biome ({biome})",
d.nation
));
}
}
}
}
w
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::compile::polities_layer::Polity;
fn pol(names: &[&str]) -> PolitiesOutput {
PolitiesOutput {
polities: names
.iter()
.map(|n| Polity {
name: (*n).into(),
capital: "the capital".into(),
capital_pos: (0, 0),
member_count: 1,
population: 1000,
})
.collect(),
relations: Vec::new(),
}
}
#[test]
fn one_culture_per_polity_with_all_facets() {
let p = pol(&["Karon", "Serai"]);
let biomes = vec!["hot_desert".to_string(), "temperate_forest".to_string()];
let c = compile_culture(&p, &biomes, &[], 0x33);
assert_eq!(c.cultures.len(), 2);
assert!(c.cultures[0].ethos.starts_with("austere and hospitable"));
assert!(c.cultures[1].ethos.starts_with("woodland-reverent"));
for cu in &c.cultures {
assert!(!cu.belief.is_empty());
assert!(cu.language_profile.contains('·'));
assert!(!cu.naming_sample.is_empty());
}
}
#[test]
fn is_deterministic() {
let p = pol(&["Karon"]);
let b = vec!["savanna".to_string()];
assert_eq!(compile_culture(&p, &b, &[], 9), compile_culture(&p, &b, &[], 9));
}
#[test]
fn roles_elaborate_into_realm_terms() {
let p = pol(&["Karon"]);
let b = vec!["mediterranean".to_string()];
let c = compile_culture(&p, &b, &[], 0x33);
let cu = &c.cultures[0];
assert!(elaborate_role("priest", cu, "mediterranean").starts_with("keeper of "));
assert!(elaborate_role("warrior", cu, "mediterranean").ends_with(" warrior"));
assert_eq!(elaborate_role("farmer", cu, "temperate_forest"), "temperate-tiller");
assert_eq!(elaborate_role("smith", cu, "mediterranean"), "forge-master");
let merc = Culture {
polity: "Karon".into(),
ethos: "mercantile and civic".into(),
belief: "a sky-pantheon".into(),
language_profile: String::new(),
naming_sample: String::new(),
};
assert_eq!(elaborate_role("merchant", &merc, "mediterranean"), "factor");
assert_eq!(elaborate_role("scribe", cu, "mediterranean"), "scribe");
}
#[test]
fn culture_style_names_are_deterministic_and_realm_distinct() {
let p = pol(&["Karon", "Serai"]);
let b = vec!["mediterranean".to_string(), "temperate_forest".to_string()];
let c = compile_culture(&p, &b, &[], 0x33);
let (k0, s0) = (&c.cultures[0], &c.cultures[1]);
assert_eq!(culture_style_name(k0, 7, 3), culture_style_name(k0, 7, 3));
let n = culture_style_name(k0, 7, 0);
assert!(n.chars().next().unwrap().is_uppercase() && n.chars().all(|ch| ch.is_alphabetic()));
let differ = (0..8).filter(|&i| culture_style_name(k0, 7, i) != culture_style_name(s0, 7, i)).count();
assert!(differ >= 5, "realms name too alike: {differ}/8 differ");
}
#[test]
fn declared_culture_overrides_and_maritime_desert_is_flagged() {
use crate::world::types::CultureDef;
let p = pol(&["Karon"]);
let b = vec!["hot_desert".to_string()];
let declared = vec![CultureDef {
nation: "Karon".into(),
ethos: "seafaring and bold".into(),
belief: "the tide-mother".into(),
language: "VSO · fusional · tonal".into(),
}];
let c = compile_culture(&p, &b, &declared, 1);
assert_eq!(c.cultures[0].ethos, "seafaring and bold");
assert_eq!(c.cultures[0].belief, "the tide-mother");
assert_eq!(c.cultures[0].language_profile, "VSO · fusional · tonal");
assert!(lint_culture(&declared, &p, &b).iter().any(|s| s.contains("seafaring")));
}
}