use crate::world::compile::culture_layer::CultureOutput;
use crate::world::compile::polities_layer::PolitiesOutput;
use crate::world::proposals::{now_secs, PlaceProposal};
fn realm_slug(name: &str) -> String {
let mut s = String::new();
for c in name.trim().to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
s.push(c);
} else if !s.ends_with('-') {
s.push('-');
}
}
s.trim_matches('-').to_string()
}
fn split_profile(profile: &str) -> (String, String, String) {
let mut parts = profile.split('·').map(|p| p.trim().to_string());
(parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), parts.next().unwrap_or_default())
}
pub fn language_proposals(pol: &PolitiesOutput, cultures: &CultureOutput, _seed: u64) -> Vec<PlaceProposal> {
let now = now_secs();
let mut out = Vec::new();
for (i, p) in pol.polities.iter().enumerate() {
let Some(c) = cultures.cultures.get(i) else { continue };
if c.language_profile.trim().is_empty() {
continue;
}
let (order, morph, sound) = split_profile(&c.language_profile);
let payload = serde_json::json!({
"realm": p.name,
"profile": c.language_profile,
"word_order": order,
"morphology": morph,
"sound": sound,
"naming_sample": c.naming_sample,
});
let rationale = format!(
"The tongue of {} — {} · sample “{}”.",
p.name, c.language_profile, c.naming_sample,
);
out.push(PlaceProposal {
id: uuid::Uuid::new_v4(),
signature: format!("language:{}", realm_slug(&p.name)),
kind: "language".into(),
name: p.name.clone(),
payload,
rationale,
status: "pending".into(),
created_at: now,
});
}
out
}
pub fn language_brief_body(p: &PlaceProposal) -> String {
let s = |k: &str| p.payload.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
format!(
"The world proposes this tongue for {}. It is a *profile*, not a finished \
language — realise it in the ConLang suite (Phonology, Grammar, Dictionary).\n\n\
Word order: {}\nMorphology: {}\nSound: {}\nNaming sample: {}\n\n\
// World × ConLang bridge — from `realworld propose-language`\n",
s("realm"),
if s("word_order").is_empty() { "—".into() } else { s("word_order") },
if s("morphology").is_empty() { "—".into() } else { s("morphology") },
if s("sound").is_empty() { "—".into() } else { s("sound") },
if s("naming_sample").is_empty() { "—".into() } else { s("naming_sample") },
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::compile::culture_layer::Culture;
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(),
}
}
fn cultures(profiles: &[(&str, &str, &str)]) -> CultureOutput {
CultureOutput {
cultures: profiles
.iter()
.map(|(realm, prof, sample)| Culture {
polity: (*realm).into(),
ethos: "settled".into(),
belief: "a sky-pantheon".into(),
language_profile: (*prof).into(),
naming_sample: (*sample).into(),
})
.collect(),
}
}
#[test]
fn one_language_per_culture_named_for_its_people() {
let props = language_proposals(
&pol(&["Karon", "Serai"]),
&cultures(&[("Karon", "SVO · fusional · tonal", "Kaeth"), ("Serai", "SOV · agglutinative · liquid", "Seren")]),
7,
);
assert_eq!(props.len(), 2);
assert_eq!(props[0].kind, "language");
assert_eq!(props[0].name, "Karon");
assert_eq!(props[0].signature, "language:karon");
let brief = language_brief_body(&props[0]);
assert!(brief.contains("SVO"));
assert!(brief.contains("fusional"));
assert!(brief.contains("tonal"));
assert!(brief.contains("Kaeth"));
}
#[test]
fn a_culture_without_a_profile_is_skipped() {
let props = language_proposals(&pol(&["Mute"]), &cultures(&[("Mute", "", "")]), 1);
assert!(props.is_empty());
}
#[test]
fn is_deterministic() {
let a = language_proposals(&pol(&["Karon"]), &cultures(&[("Karon", "VSO · isolating · guttural", "Kor")]), 9);
let b = language_proposals(&pol(&["Karon"]), &cultures(&[("Karon", "VSO · isolating · guttural", "Kor")]), 9);
assert_eq!(language_brief_body(&a[0]), language_brief_body(&b[0]));
assert_eq!(a[0].signature, b[0].signature);
}
}