use std::collections::{BTreeMap, HashMap, HashSet};
use uuid::Uuid;
use super::{parse_rel_tag, CoScene, Declared, ScenePos};
use crate::ken::grants::normalize_topic;
use crate::ken::walk::{self, ParaRef};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
use crate::world::timeline_context::{self, TlEvent};
pub(crate) struct Roster {
canonical: Vec<String>,
lc: HashMap<String, String>,
}
impl Roster {
pub(crate) fn new(pairs: &[(Uuid, String)]) -> Self {
let mut canonical = Vec::new();
let mut lc = HashMap::new();
for (_, name) in pairs {
let n = normalize_topic(name);
if n.is_empty() {
continue;
}
lc.entry(n.to_lowercase()).or_insert_with(|| n.clone());
canonical.push(n);
}
canonical.sort();
canonical.dedup();
Roster { canonical, lc }
}
pub(crate) fn resolve(&self, raw: &str) -> Option<String> {
self.lc.get(&normalize_topic(raw).to_lowercase()).cloned()
}
pub(crate) fn names(&self) -> &[String] {
&self.canonical
}
}
pub(crate) fn bonds_from_tags(paras: &[ParaRef], roster: &Roster) -> Vec<Declared> {
let mut out = Vec::new();
for p in paras {
for tag in &p.tags {
let Some((kind, a, b)) = parse_rel_tag(tag) else { continue };
let (Some(a), Some(b)) = (roster.resolve(&a), roster.resolve(&b)) else { continue };
if a != b {
out.push(Declared::new(&kind, &a, &b, p.at, p.id));
}
}
}
out
}
pub(crate) fn coscenes_from_paras(
paras: &[ParaRef],
roster: &Roster,
events: &[TlEvent],
names_by_id: &HashMap<Uuid, String>,
) -> Vec<CoScene> {
let mut anchor: BTreeMap<ScenePos, Uuid> = BTreeMap::new();
let mut text_lc: BTreeMap<ScenePos, String> = BTreeMap::new();
let mut cast: BTreeMap<ScenePos, HashSet<String>> = BTreeMap::new();
for p in paras {
anchor.entry(p.at).or_insert(p.id);
let t = text_lc.entry(p.at).or_default();
t.push_str(&p.text.to_lowercase());
t.push('\n');
if let Some(pov) = p.declared_pov.as_ref().and_then(|v| roster.resolve(v)) {
cast.entry(p.at).or_default().insert(pov);
}
}
for (sp, t) in &text_lc {
let c = cast.entry(*sp).or_default();
for name in roster.names() {
if crate::drift::mentions(t, &name.to_lowercase()) {
c.insert(name.clone());
}
}
}
let pos: HashMap<Uuid, ScenePos> = paras.iter().map(|p| (p.id, p.at)).collect();
for e in events {
let Some((anc, sp)) =
e.linked_paragraphs.iter().find_map(|p| pos.get(p).map(|sp| (*p, *sp)))
else {
continue;
};
anchor.entry(sp).or_insert(anc);
let c = cast.entry(sp).or_default();
for cid in &e.characters {
if let Some(name) = names_by_id.get(cid) {
c.insert(name.clone());
}
}
}
let mut out = Vec::new();
for (sp, c) in &cast {
if c.len() < 2 {
continue;
}
let anc = anchor.get(sp).copied().unwrap_or_else(Uuid::nil);
let mut names: Vec<&String> = c.iter().collect();
names.sort();
for i in 0..names.len() {
for j in (i + 1)..names.len() {
out.push(CoScene::new(names[i], names[j], *sp, anc));
}
}
}
out
}
pub(crate) fn build_bonds(
layout: &ProjectLayout,
h: &Hierarchy,
book: &Node,
) -> (Vec<Declared>, Vec<CoScene>, Vec<ParaRef>) {
let paras = walk::book_paras(layout, h, book);
let pairs = crate::continuity_intel::introduce::roster(h, crate::store::SYSTEM_TAG_CHARACTERS);
let roster = Roster::new(&pairs);
let names_by_id: HashMap<Uuid, String> =
pairs.iter().map(|(id, name)| (*id, normalize_topic(name))).collect();
let events = timeline_context::gather_events(h);
let declared = bonds_from_tags(¶s, &roster);
let coscenes = coscenes_from_paras(¶s, &roster, &events, &names_by_id);
(declared, coscenes, paras)
}
#[cfg(test)]
mod tests {
use super::*;
fn roster() -> Roster {
Roster::new(&[
(Uuid::from_u128(1), "Mara".into()),
(Uuid::from_u128(2), "Kell".into()),
(Uuid::from_u128(3), "Ser Danel".into()),
])
}
fn para(id: u128, ch: u32, sc: u32, tags: &[&str], text: &str, pov: Option<&str>) -> ParaRef {
ParaRef {
id: Uuid::from_u128(id),
at: ScenePos { chapter_ord: ch, scene_index: sc },
tags: tags.iter().map(|s| s.to_string()).collect(),
text: text.into(),
declared_pov: pov.map(String::from),
}
}
#[test]
fn roster_resolves_case_insensitively() {
let r = roster();
assert_eq!(r.resolve("mara").as_deref(), Some("Mara"));
assert_eq!(r.resolve(" KELL ").as_deref(), Some("Kell"));
assert_eq!(r.resolve("ser danel").as_deref(), Some("Ser Danel"));
assert!(r.resolve("Nobody").is_none());
}
#[test]
fn bonds_from_tags_keeps_resolvable_pairs_only() {
let r = roster();
let paras = vec![
para(10, 1, 1, &["rel:ally:mara:kell"], "…", None),
para(11, 2, 1, &["rel:enemy:Mara:Ghost"], "…", None), para(12, 3, 1, &["rel:kin:Mara:Mara"], "…", None), para(13, 4, 1, &["pov:Mara"], "…", None), ];
let bonds = bonds_from_tags(¶s, &r);
assert_eq!(bonds.len(), 1, "only the fully-resolvable non-self bond");
assert_eq!((bonds[0].a.as_str(), bonds[0].b.as_str()), ("Kell", "Mara"));
assert_eq!(bonds[0].kind, "ally");
}
#[test]
fn coscenes_from_mentions_and_pov() {
let r = roster();
let paras = vec![
para(20, 1, 1, &["pov:Mara"], "Kell laughed at the gate.", Some("Mara")),
para(21, 2, 1, &[], "Mara walked alone.", None),
];
let cos = coscenes_from_paras(¶s, &r, &[], &HashMap::new());
assert_eq!(cos.len(), 1, "one shared scene");
assert_eq!((cos[0].a.as_str(), cos[0].b.as_str()), ("Kell", "Mara"));
assert_eq!(cos[0].at, ScenePos { chapter_ord: 1, scene_index: 1 });
}
#[test]
fn coscenes_from_timeline_participants() {
let r = roster();
let names_by_id: HashMap<Uuid, String> =
[(Uuid::from_u128(1), "Mara".to_string()), (Uuid::from_u128(2), "Kell".to_string())]
.into_iter()
.collect();
let paras = vec![para(30, 5, 1, &[], "They rode in silence.", None)];
let events = vec![TlEvent {
id: Uuid::from_u128(99),
title: "the crossing".into(),
start_ticks: 0,
end_ticks: None,
linked_paragraphs: vec![Uuid::from_u128(30)],
characters: vec![Uuid::from_u128(1), Uuid::from_u128(2)],
places: vec![],
}];
let cos = coscenes_from_paras(¶s, &r, &events, &names_by_id);
assert_eq!(cos.len(), 1);
assert_eq!((cos[0].a.as_str(), cos[0].b.as_str()), ("Kell", "Mara"));
}
}