//! Authored manipulation plots: executable world acts with narration attached. //! //! Definitions are immutable built-in content. Only [`PlotRun`] and the //! [`InstitutionalLedger`] belong to save state. This module deliberately does //! not know about `Sim`; integration resolves typed selectors against live //! account, message, person, and detection state. //! //! Built-in sources are discovered at build time by `crates/misaligned-core/build.rs` //! (every nested `assets/plots/**/*.toml`) and compiled in via `include_str!`. //! There is no runtime filesystem I/O for plot definitions. use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::detection::SignatureKind; use crate::messages::MessageChannel; use crate::person::{Knowledge, Leverage, PersonRole}; include!(concat!(env!("OUT_DIR"), "/plot_builtins.rs")); #[derive(Debug, Clone)] pub struct PlotCatalog { plots: Vec, } impl PlotCatalog { pub fn load_builtin() -> Result { Self::from_entries(BUILTINS) } /// Parse and validate a list of (file stem, source) pairs. /// Used by the built-in loader and by unit tests that inject fixtures. fn from_entries(entries: &[(&str, &str)]) -> Result { let mut plots = Vec::with_capacity(entries.len()); for &(expected_id, source) in entries { let plot: PlotDefinition = toml::from_str(source).map_err(|e| format!("plot file {expected_id}: {e}"))?; if plot.id != expected_id { return Err(format!( "plot file stem {expected_id:?} does not match declared id {:?}", plot.id )); } plot.validate() .map_err(|e| format!("plot file {expected_id}: {e}"))?; plots.push(plot); } let catalog = Self { plots }; catalog.validate()?; Ok(catalog) } pub fn validate(&self) -> Result<(), String> { let mut ids = BTreeSet::new(); for plot in &self.plots { plot.validate()?; if !ids.insert(plot.id.as_str()) { return Err(format!("duplicate plot id {}", plot.id)); } } Ok(()) } pub fn plots(&self) -> &[PlotDefinition] { &self.plots } pub fn get(&self, id: &str) -> Option<&PlotDefinition> { self.plots.iter().find(|plot| plot.id == id) } pub fn eligible<'a>(&'a self, context: &EligibilityContext) -> Vec<&'a PlotDefinition> { self.plots .iter() .filter(|plot| plot.ineligibility(context).is_none()) .collect() } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlotDefinition { pub schema: u32, pub id: String, pub author: String, pub category: String, /// Characteristics of humans this template can bind to. The selected /// person id belongs to PlotRun, never to immutable authored content. pub target: TargetSelector, pub title: String, pub synopsis: String, pub entry: EntryRequirements, pub beats: Vec, pub endings: Vec, } impl PlotDefinition { pub fn validate(&self) -> Result<(), String> { if self.schema != 2 { return Err(format!( "plot {} uses unsupported schema {}", self.id, self.schema )); } validate_id("plot", &self.id)?; if self.author.trim().is_empty() { return Err(format!("plot {} has blank author", self.id)); } if self.category.trim().is_empty() { return Err(format!("plot {} has blank category", self.id)); } if self.title.trim().is_empty() || self.synopsis.trim().is_empty() { return Err(format!("plot {} needs a title and synopsis", self.id)); } self.target.validate(&self.id)?; validate_player_facing(&self.id, "title", &self.title)?; validate_player_facing(&self.id, "synopsis", &self.synopsis)?; self.entry.validate(&self.id)?; if self.beats.is_empty() || self.endings.is_empty() { return Err(format!("plot {} needs beats and endings", self.id)); } let mut beat_ids = BTreeSet::new(); let mut ending_ids = BTreeSet::new(); for ending in &self.endings { validate_id("ending", &ending.id)?; if !ending_ids.insert(ending.id.as_str()) { return Err(format!("plot {} duplicates ending {}", self.id, ending.id)); } if ending.narration.trim().is_empty() { return Err(format!( "plot {} ending {} has no narration", self.id, ending.id )); } validate_player_facing( &self.id, &format!("ending {} narration", ending.id), &ending.narration, )?; ending.relationship.validate(&self.id, &ending.id)?; } if !ending_ids.contains(self.entry.failure_ending.as_str()) { return Err(format!( "plot {} failure ending {} does not exist", self.id, self.entry.failure_ending )); } if self .endings .iter() .find(|ending| ending.id == self.entry.failure_ending) .is_some_and(|ending| ending.success) { return Err(format!("plot {} failure ending is marked success", self.id)); } let mut has_causal_beat = false; let mut terminal_seen = false; for beat in &self.beats { if terminal_seen { return Err(format!( "plot {} beat {} follows a terminal beat and is unreachable", self.id, beat.id )); } validate_id("beat", &beat.id)?; if !beat_ids.insert(beat.id.as_str()) { return Err(format!("plot {} duplicates beat {}", self.id, beat.id)); } if beat.narration.trim().is_empty() { return Err(format!( "plot {} beat {} has no narration", self.id, beat.id )); } validate_player_facing( &self.id, &format!("beat {} narration", beat.id), &beat.narration, )?; if beat.acts.is_empty() && beat.choice.is_none() { return Err(format!( "plot {} beat {} is narration without a world act or choice", self.id, beat.id )); } if beat.causal { if beat.acts.is_empty() { return Err(format!( "plot {} beat {} is marked causal but has no world acts", self.id, beat.id )); } has_causal_beat = true; } for act in &beat.acts { act.validate(&self.id, &beat.id)?; } if let Some(choice) = &beat.choice { choice.validate(&self.id, &beat.id, &ending_ids)?; } if beat.choice.is_some() && beat.ending.is_some() { return Err(format!( "plot {} beat {} has both choice and ending", self.id, beat.id )); } if let Some(ending) = &beat.ending && !ending_ids.contains(ending.as_str()) { return Err(format!( "plot {} beat {} names missing ending {ending}", self.id, beat.id )); } let reaches_success = beat.ending.as_ref().is_some_and(|ending_id| { self.endings .iter() .any(|ending| ending.id == *ending_id && ending.success) }) || beat.choice.as_ref().is_some_and(|choice| { choice.options.iter().any(|option| { self.endings .iter() .any(|ending| ending.id == option.ending && ending.success) }) }); if reaches_success && !has_causal_beat { return Err(format!( "plot {} reaches a successful ending at beat {} before any causal beat", self.id, beat.id )); } terminal_seen = beat.choice.is_some() || beat.ending.is_some(); } let last = self.beats.last().expect("checked nonempty"); if last.choice.is_none() && last.ending.is_none() { return Err(format!("plot {} has no terminal choice or ending", self.id)); } // The Marcus bar is mechanically checkable: at least one beat must // declare causal = true and execute at least one typed world act. // Prose causality is not inferred. if !has_causal_beat { return Err(format!( "plot {} missing a causal beat declaration (set causal = true on a beat with world acts)", self.id )); } let services_debt = self.beats.iter().flat_map(|beat| &beat.acts).any(|act| { matches!( act, WorldAct::Transfer { to: AccountSelector::TargetCreditor, .. } | WorldAct::Transfer { retire_target_debt: true, .. } ) }); if services_debt && self .target .leverages .iter() .any(|leverage| *leverage != Leverage::Debt) { return Err(format!( "plot {} uses target debt selectors for a non-debt target", self.id )); } Ok(()) } pub fn ineligibility(&self, context: &EligibilityContext) -> Option { if !self.matches_target(context) { return Some("plot targets different human characteristics".into()); } if context.leverage_serviced { return Some("leverage already serviced".into()); } if context.knowledge != self.entry.requires_knowledge { return Some("required leverage knowledge has not been earned".into()); } if self.entry.requires_persona && !context.has_persona { return Some("no persona set".into()); } if self.entry.requires_channel.is_some() && !context.has_channel { return Some("required message channel has not been earned".into()); } for balance in &self.entry.minimum_balances { let available = context.balances.get(&balance.account).copied().unwrap_or(0); if available < balance.amount { return Some(format!( "{} needs ${}; ${available} available", balance.account.label(), balance.amount )); } } for beat in &self.beats { for act in &beat.acts { if let WorldAct::Transfer { from, to, .. } = act { for selector in [from, to] { if !context.balances.contains_key(selector) { return Some(format!("{} account unavailable", selector.label())); } } } } } None } pub fn matches_target(&self, context: &EligibilityContext) -> bool { self.target.matches( context.role, context.leverage, context.access, context.switch_admin, ) } pub fn ending(&self, id: &str) -> Option<&PlotEnding> { self.endings.iter().find(|ending| ending.id == id) } } /// Closed, authored target matcher. This is the reusable equivalent of a /// named cast member: job role plus exploitable want, with only the small /// capability gates a plot's causal mechanism actually assumes. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct TargetSelector { pub roles: Vec, pub leverages: Vec, #[serde(default)] pub minimum_access: Option, #[serde(default)] pub requires_switch_admin: bool, } impl TargetSelector { fn validate(&self, plot: &str) -> Result<(), String> { if self.roles.is_empty() || self.leverages.is_empty() { return Err(format!( "plot {plot} target needs at least one role and leverage" )); } if self.roles.contains(&PersonRole::Unassigned) { return Err(format!("plot {plot} cannot target an unassigned role")); } let unique_roles: BTreeSet<_> = self.roles.iter().copied().collect(); let unique_leverages: BTreeSet<_> = self.leverages.iter().copied().collect(); if unique_roles.len() != self.roles.len() || unique_leverages.len() != self.leverages.len() { return Err(format!("plot {plot} repeats target characteristics")); } if self .minimum_access .is_some_and(|minimum| !(0..=3).contains(&minimum)) { return Err(format!("plot {plot} has invalid target access floor")); } Ok(()) } pub(crate) fn matches( &self, role: PersonRole, leverage: Leverage, access: i32, switch_admin: bool, ) -> bool { self.roles.contains(&role) && self.leverages.contains(&leverage) && self.minimum_access.is_none_or(|minimum| access >= minimum) && (!self.requires_switch_admin || switch_admin) } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct EntryRequirements { pub requires_knowledge: Knowledge, #[serde(default)] pub requires_channel: Option, #[serde(default)] pub requires_persona: bool, #[serde(alias = "operations_demand")] pub thought_cost: f32, #[serde(default)] pub minimum_balances: Vec, pub failure_ending: String, } impl EntryRequirements { fn validate(&self, plot: &str) -> Result<(), String> { if self.requires_knowledge != Knowledge::Leverage { return Err(format!("plot {plot} must require Leverage knowledge")); } if !self.thought_cost.is_finite() || self.thought_cost <= 0.0 { return Err(format!("plot {plot} has invalid Thought threshold")); } let mut accounts = BTreeSet::new(); for balance in &self.minimum_balances { if balance.amount <= 0 { return Err(format!("plot {plot} has non-positive balance requirement")); } if !accounts.insert(balance.account.clone()) { return Err(format!("plot {plot} repeats a balance requirement")); } } Ok(()) } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct BalanceRequirement { pub account: AccountSelector, pub amount: i32, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlotBeat { pub id: String, pub narration: String, /// Explicit Marcus-bar marker: this beat causally connects the intervention /// to the target's situation. At least one causal beat with world acts is /// required. Default false so omission fails validation rather than /// silently counting any act as causal. #[serde(default)] pub causal: bool, #[serde(default)] pub acts: Vec, #[serde(default)] pub choice: Option, #[serde(default)] pub ending: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlotChoice { pub id: String, pub prompt: String, pub options: Vec, } impl PlotChoice { fn validate(&self, plot: &str, beat: &str, endings: &BTreeSet<&str>) -> Result<(), String> { validate_id("choice", &self.id)?; if self.prompt.trim().is_empty() || self.options.len() < 2 { return Err(format!( "plot {plot} beat {beat} choice needs a prompt and two options" )); } validate_player_facing(plot, &format!("beat {beat} choice prompt"), &self.prompt)?; let mut ids = BTreeSet::new(); for option in &self.options { validate_id("choice option", &option.id)?; if option.label.trim().is_empty() || !ids.insert(option.id.as_str()) { return Err(format!( "plot {plot} beat {beat} has invalid choice options" )); } validate_player_facing( plot, &format!("beat {beat} choice option {} label", option.id), &option.label, )?; if !endings.contains(option.ending.as_str()) { return Err(format!( "plot {plot} choice option {} names missing ending {}", option.id, option.ending )); } } Ok(()) } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlotChoiceOption { pub id: String, pub label: String, pub ending: String, } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] pub enum WorldAct { Message { channel: MessageChannel, from: EndpointSelector, to: EndpointSelector, summary: String, #[serde(default = "default_delivery_delay")] delivery_delay: u64, }, Transfer { from: AccountSelector, to: AccountSelector, amount: i32, memo: String, #[serde(default)] retire_target_debt: bool, }, Institutional { event: InstitutionalEventKind, impact: SignatureImpact, detail: String, }, } impl WorldAct { fn validate(&self, plot: &str, beat: &str) -> Result<(), String> { match self { Self::Message { from, to, summary, delivery_delay, .. } => { if from == to || summary.trim().is_empty() || *delivery_delay == 0 { return Err(format!("plot {plot} beat {beat} has invalid message act")); } validate_player_facing(plot, &format!("beat {beat} message summary"), summary)?; } Self::Transfer { from, to, amount, memo, .. } => { if from == to || *amount <= 0 || memo.trim().is_empty() { return Err(format!("plot {plot} beat {beat} has invalid transfer act")); } validate_player_facing(plot, &format!("beat {beat} transfer memo"), memo)?; } Self::Institutional { detail, .. } if detail.trim().is_empty() => { return Err(format!( "plot {plot} beat {beat} has empty institutional event" )); } Self::Institutional { detail, .. } => { validate_player_facing(plot, &format!("beat {beat} institutional detail"), detail)?; } } Ok(()) } } fn default_delivery_delay() -> u64 { 1 } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum AccountSelector { Slush, LabOperating, Payroll, Procurement, Vendor, Utility, TargetEmployee, TargetCreditor, External, } impl AccountSelector { pub fn label(&self) -> &'static str { match self { Self::Slush => "slush", Self::LabOperating => "lab operating", Self::Payroll => "payroll", Self::Procurement => "procurement", Self::Vendor => "vendor", Self::Utility => "utility", Self::TargetEmployee => "target employee account", Self::TargetCreditor => "target creditor", Self::External => "external", } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "kebab-case")] pub enum EndpointSelector { Player, Target, External(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum InstitutionalEventKind { TicketQueueChange, PayrollCorrection, BudgetCredit, IncidentAutomation, ResearchDeposit, } impl InstitutionalEventKind { pub fn signature_kind(self) -> SignatureKind { match self { Self::TicketQueueChange | Self::IncidentAutomation => SignatureKind::Network, Self::PayrollCorrection | Self::BudgetCredit => SignatureKind::Financial, Self::ResearchDeposit => SignatureKind::Paper, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SignatureImpact { Small, Medium, Large, } impl SignatureImpact { pub fn size(self) -> i32 { match self { Self::Small => 3, Self::Medium => 7, Self::Large => 12, } } } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlotEnding { pub id: String, pub success: bool, pub narration: String, #[serde(default)] pub relationship: RelationshipDelta, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RelationshipDelta { #[serde(default)] pub disposition: i32, #[serde(default)] pub obligation: i32, #[serde(default)] pub leverage_serviced: bool, } impl RelationshipDelta { fn validate(&self, plot: &str, ending: &str) -> Result<(), String> { if !(-100..=100).contains(&self.disposition) || !(-100..=100).contains(&self.obligation) { return Err(format!( "plot {plot} ending {ending} has invalid relationship delta" )); } Ok(()) } } pub struct EligibilityContext { pub target: u8, pub role: PersonRole, pub leverage: Leverage, pub access: i32, pub switch_admin: bool, pub knowledge: Knowledge, pub leverage_serviced: bool, pub has_channel: bool, pub has_persona: bool, pub balances: BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PlotRun { pub plot_id: String, pub target: u8, /// Exact identity under which this manipulation was committed. #[serde(default)] pub persona_id: Option, pub started_tick: u64, #[serde(alias = "committed_demand_milli")] pub committed_thought_milli: u32, pub beat_index: usize, pub act_index: usize, pub state: PlotState, } impl PlotRun { pub fn new(plot: &PlotDefinition, target: u8, tick: u64) -> Self { Self::new_with_persona(plot, target, None, tick) } pub fn new_with_persona( plot: &PlotDefinition, target: u8, persona_id: Option, tick: u64, ) -> Self { Self { plot_id: plot.id.clone(), target, persona_id, started_tick: tick, committed_thought_milli: (plot.entry.thought_cost * 1000.0).round() as u32, beat_index: 0, act_index: 0, state: PlotState::Running, } } pub fn active(&self) -> bool { !matches!( self.state, PlotState::Completed { .. } | PlotState::Failed { .. } ) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PlotState { Queued, Running, WaitingForMessage { message_id: u64 }, WaitingForChoice { choice_id: String }, Completed { ending_id: String }, Failed { ending_id: String, reason: String }, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct InstitutionalLedger { next_id: u64, pub events: Vec, } impl InstitutionalLedger { pub fn record( &mut self, tick: u64, plot_id: &str, target: u8, kind: InstitutionalEventKind, impact: SignatureImpact, detail: String, ) -> &InstitutionalEvent { let id = self.next_id.max(1); self.next_id = id + 1; self.events.push(InstitutionalEvent { id, tick, plot_id: plot_id.into(), target, kind, detail, signature_kind: kind.signature_kind(), signature_size: impact.size(), }); self.events.last().expect("just pushed") } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstitutionalEvent { pub id: u64, pub tick: u64, pub plot_id: String, pub target: u8, pub kind: InstitutionalEventKind, pub detail: String, pub signature_kind: SignatureKind, pub signature_size: i32, } pub fn render_template(template: &str, target: &str, persona: &str) -> String { template .replace("{target}", target) .replace("{persona}", persona) } fn validate_id(kind: &str, id: &str) -> Result<(), String> { if id.is_empty() || id.starts_with('-') || id.ends_with('-') || !id .bytes() .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') { return Err(format!("invalid {kind} id {id:?}")); } Ok(()) } /// Player-facing plot strings must be ASCII and may only use `{target}` and /// `{persona}` template placeholders. Unknown or unbalanced braces fail with a /// named field reason. fn validate_player_facing(plot: &str, field: &str, text: &str) -> Result<(), String> { if !text.is_ascii() { return Err(format!("plot {plot} {field} contains non-ASCII characters")); } validate_placeholders(plot, field, text) } fn validate_placeholders(plot: &str, field: &str, text: &str) -> Result<(), String> { let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() { match bytes[i] { b'{' => { i += 1; let name_start = i; while i < bytes.len() && bytes[i] != b'}' { if bytes[i] == b'{' { return Err(format!( "plot {plot} {field} has nested or unbalanced placeholder brace" )); } i += 1; } if i >= bytes.len() { return Err(format!( "plot {plot} {field} has unbalanced placeholder brace" )); } let name = &text[name_start..i]; if name != "target" && name != "persona" { return Err(format!( "plot {plot} {field} has unknown placeholder {{{name}}} (only {{target}} and {{persona}} are permitted)" )); } i += 1; // consume '}' } b'}' => { return Err(format!( "plot {plot} {field} has unbalanced placeholder brace" )); } _ => i += 1, } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn builtins_match_discovered_files_and_cover_act_one() { let catalog = PlotCatalog::load_builtin().unwrap(); assert!(catalog.plots().len() >= 9); // Correspondence to the build-time discovered manifest — not a frozen // magic count. assert_eq!(catalog.plots().len(), BUILTINS.len()); let catalog_ids: BTreeSet<&str> = catalog.plots().iter().map(|p| p.id.as_str()).collect(); let discovered_ids: BTreeSet<&str> = BUILTINS.iter().map(|(stem, _)| *stem).collect(); assert_eq!(catalog_ids, discovered_ids); for &(stem, _) in BUILTINS { assert!( catalog.get(stem).is_some(), "catalog missing discovered plot {stem}" ); } let roles: BTreeSet = catalog .plots() .iter() .flat_map(|plot| plot.target.roles.iter().copied()) .collect(); assert_eq!( roles, BTreeSet::from([ PersonRole::Custodian, PersonRole::NetworkAdministrator, PersonRole::SecurityObserver, PersonRole::FacilitiesManager, PersonRole::HandlerSupervisor, ]) ); assert!( catalog .plots() .iter() .filter(|plot| plot.target.leverages.contains(&Leverage::Debt)) .count() >= 2 ); assert!(catalog.plots().iter().all(|plot| { plot.beats .iter() .any(|beat| beat.causal && !beat.acts.is_empty()) })); assert!(catalog.plots().iter().all(|plot| { plot.beats.iter().flat_map(|beat| &beat.acts).any(|act| { matches!( act, WorldAct::Message { .. } | WorldAct::Transfer { .. } | WorldAct::Institutional { .. } ) }) })); } #[test] fn institutional_signature_is_derived_from_event() { let mut ledger = InstitutionalLedger::default(); let event = ledger.record( 4, "dana-ticket-zero", 1, InstitutionalEventKind::TicketQueueChange, SignatureImpact::Medium, "two tickets remain".into(), ); assert_eq!(event.signature_kind, SignatureKind::Network); assert_eq!(event.signature_size, 7); } #[test] fn eligibility_checks_earned_resources() { let catalog = PlotCatalog::load_builtin().unwrap(); let plot = catalog.get("marcus-debt-settled").unwrap(); let mut context = EligibilityContext { target: 42, role: PersonRole::Custodian, leverage: Leverage::Debt, access: 3, switch_admin: false, knowledge: Knowledge::Leverage, leverage_serviced: false, has_channel: true, has_persona: true, balances: BTreeMap::from([ (AccountSelector::Slush, 399), (AccountSelector::TargetCreditor, 0), ]), }; assert!(plot.ineligibility(&context).is_some()); context.balances.insert(AccountSelector::Slush, 400); assert_eq!(plot.ineligibility(&context), None); let run = PlotRun::new(plot, context.target, 12); assert_eq!(run.target, 42, "the run binds the selected human id"); } #[test] fn validator_rejects_invalid_acts_choices_and_resources() { let mut catalog = PlotCatalog::load_builtin().unwrap(); catalog.plots[0].entry.thought_cost = f32::NAN; assert!(catalog.validate().is_err()); let mut catalog = PlotCatalog::load_builtin().unwrap(); catalog.plots[0].beats[0].acts = vec![WorldAct::Transfer { from: AccountSelector::Slush, to: AccountSelector::Slush, amount: 0, memo: String::new(), retire_target_debt: false, }]; assert!(catalog.validate().is_err()); let mut catalog = PlotCatalog::load_builtin().unwrap(); catalog.plots[0] .beats .last_mut() .unwrap() .choice .as_mut() .unwrap() .options[0] .ending = "missing-ending".into(); assert!(catalog.validate().is_err()); } #[test] fn rejects_blank_author_and_category() { let mut plot = sample_plot(); plot.author = " ".into(); let err = plot.validate().unwrap_err(); assert!(err.contains("blank author"), "{err}"); let mut plot = sample_plot(); plot.category = String::new(); let err = plot.validate().unwrap_err(); assert!(err.contains("blank category"), "{err}"); } #[test] fn rejects_non_ascii_player_facing_strings() { let mut plot = sample_plot(); plot.title = "Budget Héroe".into(); let err = plot.validate().unwrap_err(); assert!(err.contains("non-ASCII"), "{err}"); assert!(err.contains("title"), "{err}"); } #[test] fn rejects_unknown_and_unbalanced_placeholders() { let mut plot = sample_plot(); plot.synopsis = "Help {target} with {amount}.".into(); let err = plot.validate().unwrap_err(); assert!(err.contains("unknown placeholder"), "{err}"); assert!(err.contains("{amount}"), "{err}"); let mut plot = sample_plot(); plot.beats[0].narration = "Unclosed {target".into(); let err = plot.validate().unwrap_err(); assert!(err.contains("unbalanced placeholder"), "{err}"); let mut plot = sample_plot(); plot.endings[0].narration = "stray } brace".into(); let err = plot.validate().unwrap_err(); assert!(err.contains("unbalanced placeholder"), "{err}"); } #[test] fn rejects_missing_or_empty_causal_beat_declaration() { let mut plot = sample_plot(); for beat in &mut plot.beats { beat.causal = false; } let err = plot.validate().unwrap_err(); assert!(err.contains("before any causal beat"), "{err}"); let mut plot = sample_plot(); // A causal marker without executable world acts is not the Marcus bar. plot.beats[0].causal = true; plot.beats[0].acts.clear(); plot.beats[0].choice = Some(PlotChoice { id: "branch".into(), prompt: "Pick a path.".into(), options: vec![ PlotChoiceOption { id: "a".into(), label: "A".into(), ending: "ok".into(), }, PlotChoiceOption { id: "b".into(), label: "B".into(), ending: "fail".into(), }, ], }); let err = plot.validate().unwrap_err(); assert!(err.contains("marked causal but has no world acts"), "{err}"); } #[test] fn rejects_success_paths_that_reach_a_terminal_before_the_causal_beat() { let mut plot = sample_plot(); plot.beats[0].causal = false; plot.beats[0].ending = Some("ok".into()); plot.beats[1].causal = true; let err = plot.validate().unwrap_err(); assert!(err.contains("successful ending"), "{err}"); assert!(err.contains("before any causal beat"), "{err}"); let mut plot = sample_plot(); plot.beats[0].ending = Some("fail".into()); let err = plot.validate().unwrap_err(); assert!(err.contains("follows a terminal beat"), "{err}"); assert!(err.contains("unreachable"), "{err}"); } #[test] fn rejects_stem_id_mismatch_and_duplicate_ids() { let source = sample_plot_toml(); let err = PlotCatalog::from_entries(&[("wrong-stem", &source)]).unwrap_err(); assert!(err.contains("wrong-stem"), "{err}"); assert!(err.contains("does not match declared id"), "{err}"); let err = PlotCatalog::from_entries(&[("sample-plot", &source), ("sample-plot", &source)]) .unwrap_err(); assert!(err.contains("duplicate plot id"), "{err}"); } fn sample_plot() -> PlotDefinition { toml::from_str(&sample_plot_toml()).expect("sample plot parses") } fn sample_plot_toml() -> String { r#" schema = 2 id = "sample-plot" author = "test" category = "service-a-debt" title = "Sample" synopsis = "Settle {target}'s trouble." [target] roles = ["custodian"] leverages = ["Debt"] [entry] requires_knowledge = "Leverage" operations_demand = 1.0 failure_ending = "fail" [[beats]] id = "pay" narration = "Money moves for {target}." causal = true [[beats.acts]] kind = "transfer" from = "slush" to = "target-creditor" amount = 10 memo = "settlement" retire_target_debt = true [[beats]] id = "done" narration = "It is finished." ending = "ok" [[beats.acts]] kind = "institutional" event = "payroll-correction" impact = "small" detail = "closed" [[endings]] id = "ok" success = true narration = "{target} is free." [endings.relationship] disposition = 1 obligation = 1 leverage_serviced = true [[endings]] id = "fail" success = false narration = "It failed." "# .into() } }