use std::collections::BTreeMap;
use uuid::Uuid;
use crate::bonds::{self, BondFinding, Declared};
use crate::character::{ArcDeclaration, CharStore, CharacterState};
use crate::config::Config;
use crate::ken::grants::normalize_topic;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
#[derive(Debug, Clone)]
pub struct CastMember {
pub name: String,
pub node: Option<Uuid>,
pub arc: Option<ArcSummary>,
pub bonds: Vec<Tie>,
}
#[derive(Debug, Clone)]
pub struct ArcSummary {
pub arc_code: Option<String>,
pub current_state: Option<String>,
pub current_chapter: u32,
pub changes: usize,
pub latest_agency: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct Tie {
pub other: String,
pub kind: String,
pub chapter: u32,
}
#[derive(Debug, Clone)]
pub struct Cast {
pub book: String,
pub members: Vec<CastMember>,
pub findings: Vec<BondFinding>,
}
fn norm_key(raw: &str) -> Option<String> {
let n = normalize_topic(raw);
if n.is_empty() { None } else { Some(n.to_lowercase()) }
}
#[derive(Default)]
struct Builder {
display: Option<String>,
node: Option<Uuid>,
arc_code: Option<String>,
cur: Option<(u32, String, Option<f32>)>,
changes: usize,
bonds: Vec<Tie>,
}
fn touch(
m: &mut BTreeMap<String, Builder>,
index: &BTreeMap<String, (String, Uuid)>,
raw: &str,
) -> Option<String> {
let k = norm_key(raw)?;
let b = m.entry(k.clone()).or_default();
if b.display.is_none() {
match index.get(&k) {
Some((disp, id)) => {
b.display = Some(disp.clone());
b.node = Some(*id);
}
None => b.display = Some(normalize_topic(raw)),
}
}
Some(k)
}
fn assemble(
roster: &[(Uuid, String)],
ties: &[Declared],
declarations: &[ArcDeclaration],
states: &[CharacterState],
) -> Vec<CastMember> {
let mut index: BTreeMap<String, (String, Uuid)> = BTreeMap::new();
for (id, name) in roster {
if let Some(k) = norm_key(name) {
index.entry(k).or_insert_with(|| (normalize_topic(name), *id));
}
}
let mut m: BTreeMap<String, Builder> = BTreeMap::new();
for d in declarations {
if let Some(k) = touch(&mut m, &index, &d.character_name) {
m.get_mut(&k).unwrap().arc_code = Some(d.arc_type.as_code().to_string());
}
}
for s in states {
if let Some(k) = touch(&mut m, &index, &s.character_name) {
let b = m.get_mut(&k).unwrap();
if s.changed {
b.changes += 1;
}
let take = b.cur.as_ref().map_or(true, |(ch, _, _)| s.chapter_ord >= *ch);
if take {
b.cur = Some((s.chapter_ord, s.state_summary.clone(), s.agency_score));
}
}
}
for t in ties {
if let Some(k) = touch(&mut m, &index, &t.a) {
m.get_mut(&k)
.unwrap()
.bonds
.push(Tie { other: t.b.clone(), kind: t.kind.clone(), chapter: t.at.chapter_ord });
}
if let Some(k) = touch(&mut m, &index, &t.b) {
m.get_mut(&k)
.unwrap()
.bonds
.push(Tie { other: t.a.clone(), kind: t.kind.clone(), chapter: t.at.chapter_ord });
}
}
m.into_values()
.filter_map(|b| {
let name = b.display?;
if name.is_empty() {
return None;
}
let arc = (b.arc_code.is_some() || b.cur.is_some()).then(|| {
let (chapter, state, agency) = match b.cur {
Some((ch, st, ag)) => (ch, Some(st), ag),
None => (0, None, None),
};
ArcSummary {
arc_code: b.arc_code,
current_state: state,
current_chapter: chapter,
changes: b.changes,
latest_agency: agency,
}
});
let mut bonds = b.bonds;
bonds.sort_by(|x, y| x.other.cmp(&y.other).then(x.chapter.cmp(&y.chapter)));
bonds.dedup_by(|x, y| x.other == y.other && x.kind == y.kind && x.chapter == y.chapter);
Some(CastMember { name, node: b.node, arc, bonds })
})
.collect()
}
pub fn build_cast(layout: &ProjectLayout, h: &Hierarchy, cfg: &Config, book: &Node) -> Cast {
let roster = crate::continuity_intel::introduce::roster(h, crate::store::SYSTEM_TAG_CHARACTERS);
let ties = bonds::ties(layout, h, book);
let findings = bonds::check::run(layout, h, cfg, book);
let slug = book.slug.as_str();
let mut declarations: Vec<ArcDeclaration> = Vec::new();
let mut states: Vec<CharacterState> = Vec::new();
if let Ok(cs) = CharStore::open(&layout.root) {
declarations = cs.all_declarations(slug).unwrap_or_default();
if let Ok(names) = cs.characters_with_states(slug) {
for name in names {
if let Ok(chain) = cs.states_for_character(slug, &name) {
states.extend(chain);
}
}
}
}
let members = assemble(&roster, &ties, &declarations, &states);
Cast { book: book.title.clone(), members, findings }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bonds::ScenePos;
use crate::character::ArcType;
fn decl(name: &str) -> ArcDeclaration {
ArcDeclaration {
character_name: name.into(),
arc_type: ArcType::Corruption,
desired_state_start: String::new(),
desired_midpoint_state: None,
desired_state_end: String::new(),
}
}
fn state(name: &str, ch: u32, changed: bool, summary: &str) -> CharacterState {
CharacterState {
character_name: name.into(),
chapter_ord: ch,
state_summary: summary.into(),
changed,
change_description: None,
agency_score: Some(0.5),
active_count: 0,
passive_count: 0,
utterance_count: None,
chapter_hedge_density: None,
chapter_interiority_ratio: None,
}
}
fn tie(kind: &str, ch: u32) -> Declared {
Declared::new(kind, "Mara", "Kell", ScenePos { chapter_ord: ch, scene_index: 0 }, Uuid::from_u128(9))
}
#[test]
fn assemble_joins_roster_arc_and_bonds() {
let (mara, kell) = (Uuid::from_u128(1), Uuid::from_u128(2));
let roster = vec![(mara, "Mara".to_string()), (kell, "Kell".to_string())];
let ties = vec![tie("ally", 1)];
let decls = vec![decl("Mara")];
let states = vec![state("Mara", 1, false, "guarded"), state("Mara", 5, true, "broken")];
let cast = assemble(&roster, &ties, &decls, &states);
assert_eq!(cast.len(), 2);
let mara_m = cast.iter().find(|c| c.name == "Mara").unwrap();
assert_eq!(mara_m.node, Some(mara), "resolved to the roster node");
let arc = mara_m.arc.as_ref().unwrap();
assert_eq!(arc.arc_code.as_deref(), Some(ArcType::Corruption.as_code()));
assert_eq!(arc.current_chapter, 5, "latest state wins");
assert_eq!(arc.current_state.as_deref(), Some("broken"));
assert_eq!(arc.changes, 1);
assert_eq!(mara_m.bonds.len(), 1);
assert_eq!(mara_m.bonds[0].other, "Kell");
let kell_m = cast.iter().find(|c| c.name == "Kell").unwrap();
assert!(kell_m.arc.is_none());
assert_eq!(kell_m.bonds[0].other, "Mara");
}
#[test]
fn a_bond_only_character_off_roster_still_appears_without_a_node() {
let cast = assemble(&[], &[tie("enemy", 3)], &[], &[]);
assert_eq!(cast.len(), 2);
assert!(cast.iter().all(|c| c.node.is_none() && c.arc.is_none()));
assert!(cast.iter().any(|c| c.name == "Mara"));
}
}