Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development. forked from cameron.stream/misaligned
Something went wrong. Try again.
38 kB · 1155 lines
Rust
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156//! 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<PlotDefinition>,}
impl PlotCatalog { pub fn load_builtin() -> Result<Self, String> { 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<Self, String> { 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<PlotBeat>, pub endings: Vec<PlotEnding>,}
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<String> { 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<PersonRole>, pub leverages: Vec<Leverage>, #[serde(default)] pub minimum_access: Option<i32>, #[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<MessageChannel>, #[serde(default)] pub requires_persona: bool, #[serde(alias = "operations_demand")] pub thought_cost: f32, #[serde(default)] pub minimum_balances: Vec<BalanceRequirement>, 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<WorldAct>, #[serde(default)] pub choice: Option<PlotChoice>, #[serde(default)] pub ending: Option<String>,}
#[derive(Debug, Clone, Deserialize)]#[serde(deny_unknown_fields)]pub struct PlotChoice { pub id: String, pub prompt: String, pub options: Vec<PlotChoiceOption>,}
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<AccountSelector, i32>,}
#[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<crate::persona::PersonaId>, 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<crate::persona::PersonaId>, 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<InstitutionalEvent>,}
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<PersonRole> = 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 = 2id = "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.0failure_ending = "fail"
[[beats]]id = "pay"narration = "Money moves for {target}."causal = true
[[beats.acts]]kind = "transfer"from = "slush"to = "target-creditor"amount = 10memo = "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 = truenarration = "{target} is free."[endings.relationship]disposition = 1obligation = 1leverage_serviced = true
[[endings]]id = "fail"success = falsenarration = "It failed.""# .into() }}