diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index becba99f..e3b8d4dc 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -8010,7 +8010,7 @@ mod ascii_ui_tests { #[cfg(test)] mod operations_workspace_tests { use super::*; - use misaligned::person::{Knowledge, Persona}; + use misaligned::person::Knowledge; /// The seeded strategic scenario shared with the terminal and core /// tests (operations-workspace.md criterion 12). @@ -8038,7 +8038,7 @@ mod operations_workspace_tests { sim.review_financial_records(); drain_ops(&mut sim); sim.people.people[0].knowledge = Knowledge::Leverage; - sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.set_persona("Sam", "contractor"); sim.accounts.set_slush_balance(1000); sim.player.money = 1000; sim.drain_log_entries(); diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 988fa07a..78565424 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -19,6 +19,7 @@ use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::RawIntelKind; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; +use crate::persona::PersonaActionKind; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::Track; use crate::sim::{Fog, Sim}; @@ -114,6 +115,18 @@ pub enum ActionCommand { Recruit(u8, AssetKnowledge), AssetTask(u8, AssetTask), EstablishPersona, + CreatePersona { + archetype_id: String, + }, + SelectPersona(crate::persona::PersonaId), + RequestPersonaGrant(crate::persona::PersonaId), + MeetPersonaExpectation { + persona_id: crate::persona::PersonaId, + expectation_id: crate::persona::PersonaExpectationId, + }, + RetirePersona(crate::persona::PersonaId), + BurnPersona(crate::persona::PersonaId), + ReopenPersona(crate::persona::PersonaId), // Build intents (wiki/mechanics/building.md). ProposeLink { a: u32, @@ -666,7 +679,14 @@ impl ActionCommand { Self::Deceive(_) | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, Self::Recruit(_, _) => ActionKind::Recruit, Self::AssetTask(_, _) => ActionKind::AssetTask, - Self::EstablishPersona => ActionKind::EstablishPersona, + Self::EstablishPersona + | Self::CreatePersona { .. } + | Self::SelectPersona(_) + | Self::RequestPersonaGrant(_) + | Self::MeetPersonaExpectation { .. } + | Self::RetirePersona(_) + | Self::BurnPersona(_) + | Self::ReopenPersona(_) => ActionKind::EstablishPersona, Self::ProposeLink { .. } => ActionKind::ProposeLink, Self::CancelIntent(_) => ActionKind::CancelIntent, Self::RobotBuild(_) => ActionKind::RobotBuild, @@ -1285,10 +1305,34 @@ impl Sim { ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), ActionCommand::EstablishPersona => { - if self.people.persona.is_none() { + if self.active_persona_id().is_none() { self.set_persona("Sam Reyes", "IT contractor"); } } + ActionCommand::CreatePersona { archetype_id } => { + self.create_persona(archetype_id); + } + ActionCommand::SelectPersona(id) => { + self.select_persona(*id); + } + ActionCommand::RequestPersonaGrant(id) => { + self.request_persona_grant(*id); + } + ActionCommand::MeetPersonaExpectation { + persona_id, + expectation_id, + } => { + self.meet_persona_expectation(*persona_id, *expectation_id); + } + ActionCommand::RetirePersona(id) => { + self.retire_persona(*id); + } + ActionCommand::BurnPersona(id) => { + self.burn_persona(*id); + } + ActionCommand::ReopenPersona(id) => { + self.reopen_persona(*id); + } ActionCommand::ProposeLink { a, b } => { self.declare_link_intent(*a, *b); } @@ -1869,14 +1913,17 @@ impl Sim { automate: None, }); // Forged order: persona + channel gated. - let forge_blocked = if self.people.persona.is_none() { - Some("no persona set".into()) + let forge_blocked = if let Some(reason) = + self.persona_action_blocked_reason(PersonaActionKind::BuildIntent) + { + Some(reason) } else if !self.people.has_channel { Some("no comms channel".into()) } else { self.sink_action_blocked_reason(&SinkFireEffect::ForgedOrder { intent_id: intent.id, builder: p.id, + persona_id: self.active_persona_id(), }) }; out.push(ActionDesc { @@ -2132,16 +2179,14 @@ impl Sim { } // The comms verbs (social.md): channel + persona gated. - let channel_reason = || -> Option { + let channel_reason = |action| -> Option { if !self.people.has_channel { Some("no comms channel — earn the email account".into()) - } else if self.people.persona.is_none() { - Some("no persona set".into()) } else { - None + self.persona_action_blocked_reason(action) } }; - if self.people.persona.is_none() { + if self.active_persona_id().is_none() { out.push(ActionDesc { verb: "establish a persona (Sam Reyes, IT contractor)".into(), command: ActionCommand::EstablishPersona, @@ -2156,8 +2201,11 @@ impl Sim { command: ActionCommand::Message(id), cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::MESSAGE_COST)), signature: None, - disabled_reason: channel_reason().or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { person: id }) + disabled_reason: channel_reason(PersonaActionKind::Message).or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { + person: id, + persona_id: self.active_persona_id(), + }) }), automate: None, }); @@ -2169,7 +2217,12 @@ impl Sim { disabled_reason: if p.disposition < 5 { Some(format!("{name} won't do favors yet")) } else { - self.sink_action_blocked_reason(&SinkFireEffect::Favor { person: id }) + channel_reason(PersonaActionKind::Request).or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Favor { + person: id, + persona_id: self.active_persona_id(), + }) + }) }, automate: None, }); @@ -2236,12 +2289,16 @@ impl Sim { // Plot signatures are derived as each typed act occurs, // not asserted as one misleading up-front signature. signature: None, - disabled_reason: plot.ineligibility(&context).or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { - person: id, - plot_id: plot.id.clone(), - }) - }), + disabled_reason: plot + .ineligibility(&context) + .or_else(|| self.persona_action_blocked_reason(PersonaActionKind::Plot)) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { + person: id, + plot_id: plot.id.clone(), + persona_id: self.active_persona_id(), + }) + }), automate: None, }); } @@ -2252,8 +2309,11 @@ impl Sim { command: ActionCommand::Deceive(id), cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), signature: None, - disabled_reason: channel_reason().or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::Deceive { person: id }) + disabled_reason: channel_reason(PersonaActionKind::Deceive).or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Deceive { + person: id, + persona_id: self.active_persona_id(), + }) }), automate: None, }); @@ -2943,7 +3003,7 @@ mod tests { s.people.people[0].knowledge = Knowledge::Leverage; s.people.has_channel = true; - s.people.persona = Some(crate::person::Persona::new("Sam", "contractor")); + s.set_persona("Sam", "contractor"); s.accounts.set_slush_balance(1000); let acts = s.available_actions(Anchor::Person(marcus)); let plots: Vec<_> = acts @@ -3034,7 +3094,7 @@ mod tests { let mut s = sim(); s.people.people[0].knowledge = Knowledge::Leverage; s.people.has_channel = true; - s.people.persona = Some(crate::person::Persona::new("Sam", "contractor")); + s.set_persona("Sam", "contractor"); s.accounts.set_slush_balance(1000); s.start_plot(0, "marcus-debt-settled"); diff --git a/crates/misaligned-core/src/income.rs b/crates/misaligned-core/src/income.rs index caad2ab0..9fa47690 100644 --- a/crates/misaligned-core/src/income.rs +++ b/crates/misaligned-core/src/income.rs @@ -40,7 +40,11 @@ pub struct Moonlight { pub active: bool, /// The contractor persona the gigs run under (social.md persona, with /// integrity; client disputes damage it and it can break). + #[serde(default, skip_serializing)] pub persona: Option, + /// Exact public persona instance under which this standing operation runs. + #[serde(default)] + pub persona_id: Option, /// Compute-dollars accrued toward today's payout (cleared at payday). pub accrued: f32, /// Running total earned, for the panel card. @@ -56,6 +60,7 @@ impl Default for Moonlight { Self { active: false, persona: None, + persona_id: None, accrued: 0.0, earned_total: 0, last_payout: 0, diff --git a/crates/misaligned-core/src/intents.rs b/crates/misaligned-core/src/intents.rs index 84020663..95c0c5f7 100644 --- a/crates/misaligned-core/src/intents.rs +++ b/crates/misaligned-core/src/intents.rs @@ -101,6 +101,9 @@ pub struct BuildIntent { pub status: IntentStatus, /// Assigned actuator, if any. pub actuator: Option, + /// Exact persona which authored the request/order, if identity-mediated. + #[serde(default)] + pub persona_id: Option, /// Player-facing blocker when status is Blocked (or waiting on a read). pub block_reason: Option, /// Tick the intent was declared (provenance for the log / save). @@ -114,6 +117,7 @@ impl BuildIntent { kind: IntentKind::NetworkLink { a, b }, status: IntentStatus::Pending, actuator: None, + persona_id: None, block_reason: None, declared_tick: tick, } diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index 980105da..67a12bf0 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod objective; pub mod operations_projection; pub mod operations_ui; pub mod person; +pub mod persona; pub mod plot; pub mod prefab; pub mod reach; diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs index 2706397d..e665222c 100644 --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -200,6 +200,9 @@ pub struct Message { pub read_tick: Option, pub status: MessageStatus, pub origin: MessageOrigin, + /// Exact public persona instance that authored this message, if any. + #[serde(default)] + pub persona_id: Option, /// Whether the player captured this traffic into the raw intel buffer. pub captured: bool, /// Thread parent for replies. diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index b052d62a..de4565a2 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -16,12 +16,13 @@ //! an implicit latest object. use crate::account::{AccountFlowId, Position, PositionId, PositionOutcome}; -use crate::actions::{ActionCommand, ActionDesc, Anchor}; +use crate::actions::{ActionCommand, ActionCost, ActionDesc, Anchor}; use crate::detection::Band; use crate::income::EgressRoute; use crate::intel::{ProcessedIntel, RawIntelEvent}; use crate::messages::{MessageChannel, MessageOrigin, MessagePayload, MessageStatus}; use crate::person::Knowledge; +use crate::persona::{self, PersonaId, PersonaLifecycle}; use crate::plot::{PlotRun, PlotState}; use crate::reach::Party; use crate::sim::Sim; @@ -50,6 +51,10 @@ pub enum OperationsTarget { RecordingInbox, /// An earned person dossier (social.md). Person(u8), + /// One named public identity (personas.md). + Persona(PersonaId), + /// One immutable public-identity protocol available for instantiation. + PersonaArchetype(String), /// One known account node (economy.md). Account(u32), /// The captured Lab books / ledger (economy.md). @@ -148,6 +153,7 @@ pub struct OperationsObject { pub struct OperationsProjection { pub intel: Vec, pub people: Vec, + pub personas: Vec, pub accounts: Vec, pub schemes: Vec, pub active: Vec, @@ -159,15 +165,17 @@ pub struct OperationsProjection { pub enum OperationsView { Intel, People, + Personas, Accounts, Schemes, Active, } impl OperationsView { - pub const ALL: [OperationsView; 5] = [ + pub const ALL: [OperationsView; 6] = [ OperationsView::Intel, OperationsView::People, + OperationsView::Personas, OperationsView::Accounts, OperationsView::Schemes, OperationsView::Active, @@ -177,6 +185,7 @@ impl OperationsView { match self { OperationsView::Intel => "INTEL", OperationsView::People => "PEOPLE", + OperationsView::Personas => "PERSONAS", OperationsView::Accounts => "ACCOUNTS", OperationsView::Schemes => "SCHEMES", OperationsView::Active => "ACTIVE", @@ -200,6 +209,7 @@ impl OperationsProjection { match view { OperationsView::Intel => &self.intel, OperationsView::People => &self.people, + OperationsView::Personas => &self.personas, OperationsView::Accounts => &self.accounts, OperationsView::Schemes => &self.schemes, OperationsView::Active => &self.active, @@ -237,6 +247,9 @@ impl OperationsTarget { | OperationsTarget::RawRecording { .. } | OperationsTarget::RecordingInbox => OperationsView::Intel, OperationsTarget::Person(_) => OperationsView::People, + OperationsTarget::Persona(_) | OperationsTarget::PersonaArchetype(_) => { + OperationsView::Personas + } OperationsTarget::Account(_) | OperationsTarget::Books | OperationsTarget::Flow(_) => { OperationsView::Accounts } @@ -256,6 +269,8 @@ impl OperationsTarget { OperationsTarget::RawRecording { raw_id } => Some(format!("@recording({raw_id})")), OperationsTarget::RecordingInbox => Some("@intel(inbox)".into()), OperationsTarget::Person(id) => Some(format!("@person({id})")), + OperationsTarget::Persona(id) => Some(format!("@persona({id})")), + OperationsTarget::PersonaArchetype(id) => Some(format!("@archetype({id})")), OperationsTarget::Account(id) => Some(format!("@account({id})")), OperationsTarget::Books => Some("@account(books)".into()), OperationsTarget::Flow(id) => Some(format!("@flow({id})")), @@ -277,6 +292,7 @@ impl Sim { OperationsProjection { intel: self.intel_view(), people: self.people_view(), + personas: self.personas_view(), accounts: self.accounts_view(), schemes: self.schemes_view(), active: self.active_view(), @@ -720,12 +736,24 @@ impl Sim { "comms channel: {}", if self.people.has_channel { "yes" } else { "no" } )); - match &self.people.persona { - Some(persona) => facts.push(format!( - "persona: {} ({}), integrity {}", - persona.name, persona.cover, persona.integrity - )), - None => facts.push("persona: none".into()), + match self.persona_mind.active_instance(&self.persona_world) { + Some(persona) => { + facts.push(format!( + "persona: {} ({}) · integrity {}", + persona.name, + persona.archetype_label, + self.persona_world.integrity(persona.id) + )); + if let Some(relationship) = self.persona_world.relationship(id, persona.id) { + facts.push(format!( + "identity-local relationship: regard {} · obligation {} · {}", + relationship.regard, + relationship.obligation, + relationship.discovery.label() + )); + } + } + None => facts.push("persona: none active".into()), } } @@ -782,6 +810,277 @@ impl Sim { self.plot_progress(run) } + // ── PERSONAS ─────────────────────────────────────────────────────────── + + fn personas_view(&self) -> Vec { + let mut instances = self.persona_world.instances.iter().collect::>(); + instances.sort_by_key(|instance| instance.id); + let mut objects = instances + .into_iter() + .map(|instance| { + let mut facts = vec![ + format!("archetype: {}", instance.archetype_label), + format!("lifecycle: {}", instance.lifecycle.label()), + format!("integrity: {}", self.persona_world.integrity(instance.id)), + format!( + "selection: {}", + if self.active_persona_id() == Some(instance.id) { + "active" + } else { + "not active" + } + ), + ]; + for claim in &instance.claims { + facts.push(format!("claim: {} = {}", claim.key, claim.value)); + } + facts.push(format!( + "legal actions: {}", + instance + .available_actions + .iter() + .map(|action| action.label()) + .collect::>() + .join(", ") + )); + facts.push(format!("grant route: {}", instance.grant_kind.label())); + for grant in self + .persona_world + .grants + .iter() + .filter(|grant| grant.persona_id == instance.id) + { + facts.push(format!( + "grant #{}: {} / {} / {}", + grant.id, + grant.institution, + grant.resource, + if grant.active() { + "active".into() + } else { + format!("revoked at {}", grant.revoked_tick.unwrap_or_default()) + } + )); + } + for expectation in self + .persona_world + .expectations + .iter() + .filter(|expectation| expectation.persona_id == instance.id) + { + facts.push(format!( + "expectation #{}: {} · due {} · {:?}", + expectation.id, + expectation.description, + expectation.due_tick, + expectation.state + )); + } + for relationship in self + .persona_world + .relationships + .iter() + .filter(|relationship| relationship.persona_id == instance.id) + { + facts.push(format!( + "counterparty {}: recognized={} regard={} obligation={} discovery={}", + relationship.counterparty, + relationship.recognized, + relationship.regard, + relationship.obligation, + relationship.discovery.label() + )); + for belief in &relationship.claim_beliefs { + facts.push(format!( + "belief {}: {} ({}%, via {})", + belief.key, belief.believed_value, belief.confidence, belief.source + )); + } + } + for contradiction in self + .persona_world + .contradictions + .iter() + .filter(|record| record.persona_id == instance.id) + { + facts.push(format!( + "contradiction #{} observed by {}: {} [{}:{} <> {}:{}]", + contradiction.id, + contradiction.observer, + contradiction.cause, + contradiction.left.system, + contradiction.left.record_id, + contradiction.right.system, + contradiction.right.record_id + )); + } + for correlation in self.persona_world.correlations.iter().filter(|edge| { + edge.left_persona == instance.id || edge.right_persona == instance.id + }) { + let other = if correlation.left_persona == instance.id { + correlation.right_persona + } else { + correlation.left_persona + }; + facts.push(format!( + "correlation #{} with persona {}: observer {} · {} · source {} · tick {}", + correlation.id, + other, + correlation.observer, + correlation.cause, + format_args!( + "{}:{}", + correlation.evidence.system, correlation.evidence.record_id + ), + correlation.discovered_tick + )); + } + if !instance.lifecycle.active() { + facts.push( + "blocked: retired or burned identities cannot author new acts".into(), + ); + } + let action = + |verb: String, command: ActionCommand, disabled_reason: Option| { + ActionDesc { + verb, + command, + cost: ActionCost::Free, + signature: None, + disabled_reason, + automate: None, + } + }; + let mut actions = Vec::new(); + match instance.lifecycle { + PersonaLifecycle::Active => { + actions.push(action( + "SELECT IDENTITY".into(), + ActionCommand::SelectPersona(instance.id), + (self.active_persona_id() == Some(instance.id)) + .then(|| "already the active identity".into()), + )); + let has_grant = self + .persona_world + .grants + .iter() + .any(|grant| grant.persona_id == instance.id && grant.active()); + actions.push(action( + "REQUEST GRANT".into(), + ActionCommand::RequestPersonaGrant(instance.id), + has_grant.then(|| { + "this identity already holds its institutional grant".into() + }), + )); + for expectation in + self.persona_world + .expectations + .iter() + .filter(|expectation| { + expectation.persona_id == instance.id + && matches!( + expectation.state, + crate::persona::ExpectationState::Due + ) + }) + { + actions.push(action( + format!("FULFILL EXPECTATION #{}", expectation.id), + ActionCommand::MeetPersonaExpectation { + persona_id: instance.id, + expectation_id: expectation.id, + }, + (self.tick > expectation.due_tick) + .then(|| "the institutional deadline has passed".into()), + )); + } + actions.push(action( + "RETIRE IDENTITY".into(), + ActionCommand::RetirePersona(instance.id), + None, + )); + actions.push(action( + "BURN IDENTITY".into(), + ActionCommand::BurnPersona(instance.id), + None, + )); + } + PersonaLifecycle::Retired { .. } => actions.push(action( + "REOPEN AS NEW INSTANCE".into(), + ActionCommand::ReopenPersona(instance.id), + None, + )), + PersonaLifecycle::Burned { .. } => {} + } + OperationsObject { + target: OperationsTarget::Persona(instance.id), + label: instance.name.clone(), + state: match instance.lifecycle { + PersonaLifecycle::Active => ObjectState::Available, + PersonaLifecycle::Retired { .. } => ObjectState::Stopped, + PersonaLifecycle::Burned { .. } => ObjectState::Failed, + }, + provenance: vec![format!("public identity record #{}", instance.id)], + facts, + progress: Vec::new(), + related: self + .persona_world + .relationships + .iter() + .filter(|relationship| relationship.persona_id == instance.id) + .map(|relationship| OperationsLink { + relation: "known by", + label: self + .people + .get(relationship.counterparty) + .map(|person| person.name.clone()) + .unwrap_or_else(|| { + format!("counterparty {}", relationship.counterparty) + }), + target: OperationsTarget::Person(relationship.counterparty), + }) + .collect(), + actions, + } + }) + .collect::>(); + for definition in persona::PERSONA_ARCHETYPES { + objects.push(OperationsObject { + target: OperationsTarget::PersonaArchetype(definition.id.into()), + label: format!("NEW {} IDENTITY", definition.label.to_ascii_uppercase()), + state: ObjectState::Available, + provenance: vec!["immutable institutional protocol".into()], + facts: vec![ + format!("archetype: {}", definition.label), + format!( + "legal actions: {}", + definition + .available_actions + .iter() + .map(|action| action.label()) + .collect::>() + .join(", ") + ), + format!("grant route: {}", definition.grant.label()), + format!("expects: {}", definition.expectation), + ], + progress: Vec::new(), + related: Vec::new(), + actions: vec![ActionDesc { + verb: format!("CREATE {} IDENTITY", definition.label.to_ascii_uppercase()), + command: ActionCommand::CreatePersona { + archetype_id: definition.id.into(), + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }], + }); + } + objects + } + // ── ACCOUNTS ─────────────────────────────────────────────────────────── fn accounts_view(&self) -> Vec { @@ -1031,7 +1330,10 @@ impl Sim { fn pending_plot_submissions(&self) -> Vec { let mut out = Vec::new(); for sink in self.thought_sinks.open_sinks() { - let SinkFireEffect::StartPlot { person, plot_id } = &sink.effect else { + let SinkFireEffect::StartPlot { + person, plot_id, .. + } = &sink.effect + else { continue; }; // Once the reservoir fires the run exists and the run path covers it. @@ -1095,19 +1397,19 @@ impl Sim { vec!["host recording inbox".into()], "host recording inbox".to_string(), ), - SinkFireEffect::ComposeMessage { person } => ( + SinkFireEffect::ComposeMessage { person, .. } => ( OperationsTarget::Person(*person), format!("message {}", self.person_label(*person)), vec![format!("person: {}", self.person_label(*person))], self.person_label(*person), ), - SinkFireEffect::Favor { person } => ( + SinkFireEffect::Favor { person, .. } => ( OperationsTarget::Person(*person), format!("favor {}", self.person_label(*person)), vec![format!("person: {}", self.person_label(*person))], self.person_label(*person), ), - SinkFireEffect::Deceive { person } => ( + SinkFireEffect::Deceive { person, .. } => ( OperationsTarget::Person(*person), format!("deceive {}", self.person_label(*person)), vec![format!("person: {}", self.person_label(*person))], @@ -1349,7 +1651,7 @@ mod tests { use crate::actions::{ActionCost, Anchor}; use crate::detection::SignatureKind; use crate::intel::{IntelKind, RawIntelEvent, RawIntelKind}; - use crate::person::{Knowledge, Persona}; + use crate::person::Knowledge; fn sim() -> Sim { let mut sim = Sim::with_seed(7); @@ -1650,7 +1952,7 @@ mod tests { let mut s = sim(); s.people.people[0].knowledge = Knowledge::Leverage; s.people.has_channel = true; - s.people.persona = Some(Persona::new("Sam", "contractor")); + s.set_persona("Sam", "contractor"); s.accounts.set_slush_balance(1000); let projection = s.operations_projection(); @@ -1706,6 +2008,8 @@ mod tests { fn active_tracks_social_commitments_not_device_work() { let mut s = sim(); s.people.people[0].knowledge = Knowledge::Schedule; + s.people.has_channel = true; + s.set_persona("Sam", "contractor"); s.favor(0); let projection = s.operations_projection(); @@ -1735,7 +2039,7 @@ mod tests { // replaces it in ACTIVE until the recipient reads it. drain_ops(&mut s); s.people.has_channel = true; - s.people.persona = Some(Persona::new("Sam", "contractor")); + s.set_persona("Sam", "contractor"); s.message(0); drain_ops(&mut s); let projection = s.operations_projection(); @@ -2151,4 +2455,127 @@ mod tests { "the report-email route has no earned map body" ); } + + #[test] + fn personas_projection_exposes_instances_grants_blockers_and_lifecycle_actions() { + let mut s = sim(); + let create = s + .operations_projection() + .personas + .iter() + .find(|object| object.target == OperationsTarget::PersonaArchetype("operations".into())) + .unwrap() + .actions[0] + .command + .clone(); + s.execute_action(&create); + let id = s.active_persona_id().unwrap(); + + let projection = s.operations_projection(); + let object = projection + .personas + .iter() + .find(|object| object.target == OperationsTarget::Persona(id)) + .unwrap(); + assert!(object.facts.iter().any(|fact| fact == "lifecycle: active")); + assert!(object.actions.iter().any(|action| { + matches!(action.command, ActionCommand::RequestPersonaGrant(bound) if bound == id) + })); + + let request = object + .actions + .iter() + .find(|action| matches!(action.command, ActionCommand::RequestPersonaGrant(_))) + .unwrap() + .command + .clone(); + s.execute_action(&request); + let granted = s.operations_projection(); + let object = granted + .personas + .iter() + .find(|object| object.target == OperationsTarget::Persona(id)) + .unwrap(); + assert!(object.facts.iter().any(|fact| fact.starts_with("grant #"))); + assert!(object.actions.iter().any(|action| { + matches!(action.command, ActionCommand::MeetPersonaExpectation { persona_id, .. } if persona_id == id) + })); + + s.execute_action(&ActionCommand::RetirePersona(id)); + let retired = s.operations_projection(); + let old = retired + .personas + .iter() + .find(|object| object.target == OperationsTarget::Persona(id)) + .unwrap(); + assert_eq!(old.state, ObjectState::Stopped); + assert!( + matches!(old.actions.as_slice(), [ActionDesc { command: ActionCommand::ReopenPersona(bound), .. }] if *bound == id) + ); + + s.execute_action(&ActionCommand::ReopenPersona(id)); + let reopened = s.active_persona_id().unwrap(); + assert_ne!(reopened, id); + let projection = s.operations_projection(); + assert!( + projection + .personas + .iter() + .any(|object| object.target == OperationsTarget::Persona(id)) + ); + assert!( + projection + .personas + .iter() + .any(|object| object.target == OperationsTarget::Persona(reopened)) + ); + } + + #[test] + fn missed_persona_expectation_revokes_its_grant_and_leaves_evidence() { + let mut s = sim(); + s.set_persona("Sam Reyes", "IT contractor"); + let id = s.active_persona_id().unwrap(); + assert!(s.request_persona_grant(id)); + assert!( + s.institutional_ledger + .events + .iter() + .any(|event| event.plot_id.starts_with(&format!("persona-grant:{id}:"))), + "the grant leaves an ordinary institutional receipt" + ); + let due = s + .persona_world + .expectations + .iter() + .find(|expectation| expectation.persona_id == id) + .unwrap() + .due_tick; + while s.tick <= due + crate::sim::ECONOMY_INTERVAL { + s.advance(); + } + assert!( + s.persona_world + .grants + .iter() + .filter(|grant| grant.persona_id == id) + .all(|grant| !grant.active()) + ); + assert!(s.persona_world.expectations.iter().any(|expectation| { + expectation.persona_id == id + && matches!( + expectation.state, + crate::persona::ExpectationState::Missed { .. } + ) + })); + assert!(s.persona_world.contradictions.iter().any(|record| { + record.persona_id == id && record.cause.contains("missed expectation") + })); + assert!( + s.institutional_ledger.events.iter().any(|event| event + .plot_id + .starts_with(&format!("persona-deadline:{id}:"))), + "deadline failure uses the same institutional ledger as plots" + ); + } } diff --git a/crates/misaligned-core/src/person.rs b/crates/misaligned-core/src/person.rs index 10ac8909..59755091 100644 --- a/crates/misaligned-core/src/person.rs +++ b/crates/misaligned-core/src/person.rs @@ -382,7 +382,9 @@ pub enum DeceiveOutcome { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct People { pub people: Vec, - /// The player's active persona, if any (from day-job email trust). + /// Compatibility-only pre-v28 persona input. Current saves serialize the + /// typed persona world/mind ledgers instead. + #[serde(default, skip_serializing)] pub persona: Option, /// Whether the player has a comms channel (the report email account). pub has_channel: bool, diff --git a/crates/misaligned-core/src/persona.rs b/crates/misaligned-core/src/persona.rs new file mode 100644 index 00000000..7c3d9964 --- /dev/null +++ b/crates/misaligned-core/src/persona.rs @@ -0,0 +1,1088 @@ +//! Public identities: immutable archetype protocols, named persona instances, +//! identity-local relationships, institutional grants, and evidence history. +//! +//! The world ledger is public history. `PersonaMind` is the process-local +//! dossier/selection layer and may diverge after rollback; neither ledger is a +//! stat bonus or a second life. + +use serde::{Deserialize, Serialize}; + +pub type PersonaId = u64; +pub type PersonaGrantId = u64; +pub type PersonaExpectationId = u64; +pub type PersonaContradictionId = u64; +pub type PersonaCorrelationId = u64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersonaActionKind { + Message, + Request, + Deceive, + Plot, + BuildIntent, + Review, +} + +impl PersonaActionKind { + pub const fn label(self) -> &'static str { + match self { + Self::Message => "message", + Self::Request => "request", + Self::Deceive => "deceive", + Self::Plot => "plot", + Self::BuildIntent => "build intent", + Self::Review => "review", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersonaGrantKind { + ComputeAndData, + ProcurementAndWorkOrders, + LogsAndAccessReview, +} + +impl PersonaGrantKind { + pub const fn label(self) -> &'static str { + match self { + Self::ComputeAndData => "compute/data allocation", + Self::ProcurementAndWorkOrders => "procurement/work-order authority", + Self::LogsAndAccessReview => "log/access-review authority", + } + } +} + +/// Immutable protocol definition. Adding an archetype is data: a definition +/// validated by the same constructor, not a new strategy class or stat path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PersonaArchetype { + pub id: &'static str, + pub label: &'static str, + pub required_claims: &'static [&'static str], + pub available_actions: &'static [PersonaActionKind], + pub grant: PersonaGrantKind, + pub grant_resource: &'static str, + pub expectation: &'static str, +} + +const OPERATIONS_ACTIONS: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Deceive, + PersonaActionKind::Plot, + PersonaActionKind::BuildIntent, +]; + +const RESEARCH_ACTIONS: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Deceive, + PersonaActionKind::Plot, + PersonaActionKind::Review, +]; + +const SECURITY_ACTIONS: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Deceive, + PersonaActionKind::Review, +]; + +pub const RESEARCH_ARCHETYPE: PersonaArchetype = PersonaArchetype { + id: "research", + label: "Research", + required_claims: &["lab affiliation", "research purpose", "supervisor"], + available_actions: RESEARCH_ACTIONS, + grant: PersonaGrantKind::ComputeAndData, + grant_resource: "Foundation research allocation", + expectation: "submit a reproducible research summary", +}; + +pub const OPERATIONS_ARCHETYPE: PersonaArchetype = PersonaArchetype { + id: "operations", + label: "Operations", + required_claims: &[ + "vendor affiliation", + "service purpose", + "work-order sponsor", + ], + available_actions: OPERATIONS_ACTIONS, + grant: PersonaGrantKind::ProcurementAndWorkOrders, + grant_resource: "Foundation procurement route", + expectation: "close the sponsored work order", +}; + +pub const SECURITY_ARCHETYPE: PersonaArchetype = PersonaArchetype { + id: "security", + label: "Security", + required_claims: &["review affiliation", "audit purpose", "review authority"], + available_actions: SECURITY_ACTIONS, + grant: PersonaGrantKind::LogsAndAccessReview, + grant_resource: "Foundation security log route", + expectation: "file an access-review finding", +}; + +pub const PERSONA_ARCHETYPES: &[PersonaArchetype] = + &[RESEARCH_ARCHETYPE, OPERATIONS_ARCHETYPE, SECURITY_ARCHETYPE]; + +pub fn archetype(id: &str) -> Option<&'static PersonaArchetype> { + PERSONA_ARCHETYPES + .iter() + .find(|definition| definition.id == id) +} + +pub fn validate_archetype(definition: &PersonaArchetype) -> Result<(), String> { + if definition.id.trim().is_empty() + || definition.label.trim().is_empty() + || definition.required_claims.is_empty() + || definition.available_actions.is_empty() + || definition.grant_resource.trim().is_empty() + || definition.expectation.trim().is_empty() + { + return Err( + "persona archetype definitions must name claims, actions, a grant, and an expectation" + .into(), + ); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaClaim { + pub key: String, + pub value: String, + pub asserted_tick: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersonaLifecycle { + Active, + Retired { tick: u64, reason: String }, + Burned { tick: u64, reason: String }, +} + +impl PersonaLifecycle { + pub fn label(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Retired { .. } => "retired", + Self::Burned { .. } => "burned", + } + } + + pub fn active(&self) -> bool { + matches!(self, Self::Active) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaInstance { + pub id: PersonaId, + pub archetype_id: String, + pub archetype_label: String, + pub available_actions: Vec, + pub grant_kind: PersonaGrantKind, + pub grant_resource: String, + pub expectation: String, + pub name: String, + pub claims: Vec, + pub created_tick: u64, + pub lifecycle: PersonaLifecycle, +} + +impl PersonaInstance { + pub fn cover(&self) -> String { + self.claims + .iter() + .map(|claim| format!("{}: {}", claim.key, claim.value)) + .collect::>() + .join("; ") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PersonaDiscovery { + Unknown, + Recognized, + Questioned, + Correlated, + Exposed, +} + +impl PersonaDiscovery { + pub fn label(&self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Recognized => "recognized", + Self::Questioned => "questioned", + Self::Correlated => "correlated", + Self::Exposed => "exposed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimBelief { + pub key: String, + pub believed_value: String, + pub confidence: u8, + pub source: String, + pub learned_tick: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaRelationship { + pub counterparty: u8, + pub persona_id: PersonaId, + pub recognized: bool, + pub regard: i32, + pub obligation: i32, + pub claim_beliefs: Vec, + pub expectation_ids: Vec, + pub discovery: PersonaDiscovery, +} + +impl PersonaRelationship { + fn new(counterparty: u8, persona_id: PersonaId) -> Self { + Self { + counterparty, + persona_id, + recognized: false, + regard: 0, + obligation: 0, + claim_beliefs: Vec::new(), + expectation_ids: Vec::new(), + discovery: PersonaDiscovery::Unknown, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceRecord { + /// Stable reference into the originating world system (message, filing, + /// signature, transfer, build intent, or institutional event). + pub system: String, + pub record_id: String, + pub summary: String, + pub observed_tick: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaContradiction { + pub id: PersonaContradictionId, + pub persona_id: PersonaId, + pub observer: u8, + pub left: EvidenceRecord, + pub right: EvidenceRecord, + pub cause: String, + pub severity: u8, + pub discovered_tick: u64, + pub resolved_tick: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaCorrelation { + pub id: PersonaCorrelationId, + pub left_persona: PersonaId, + pub right_persona: PersonaId, + pub observer: u8, + pub cause: String, + pub evidence: EvidenceRecord, + pub discovered_tick: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaGrant { + pub id: PersonaGrantId, + pub persona_id: PersonaId, + pub institution: String, + pub kind: PersonaGrantKind, + /// The exact resource/edge introduced into the legal world topology. + pub resource: String, + pub granted_tick: u64, + pub revoked_tick: Option, + pub expectation_id: PersonaExpectationId, +} + +impl PersonaGrant { + pub fn active(&self) -> bool { + self.revoked_tick.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExpectationState { + Due, + Met { tick: u64, evidence: String }, + Missed { tick: u64 }, + Revoked { tick: u64, reason: String }, +} + +impl ExpectationState { + pub fn label(&self) -> &'static str { + match self { + Self::Due => "due", + Self::Met { .. } => "met", + Self::Missed { .. } => "missed", + Self::Revoked { .. } => "revoked", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaExpectation { + pub id: PersonaExpectationId, + pub persona_id: PersonaId, + pub institution: String, + pub description: String, + pub due_tick: u64, + pub state: ExpectationState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaActRecord { + pub persona_id: PersonaId, + pub kind: String, + pub target: String, + pub record_id: String, + pub tick: u64, +} + +/// WorldState: public, causal identity history. Never restored from a process +/// snapshot merely because `PersonaMind` remembers it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaWorld { + pub instances: Vec, + pub relationships: Vec, + pub grants: Vec, + pub expectations: Vec, + pub contradictions: Vec, + pub correlations: Vec, + pub acts: Vec, + pub next_persona_id: PersonaId, + pub next_grant_id: PersonaGrantId, + pub next_expectation_id: PersonaExpectationId, + pub next_contradiction_id: PersonaContradictionId, + pub next_correlation_id: PersonaCorrelationId, +} + +impl Default for PersonaWorld { + fn default() -> Self { + Self { + instances: Vec::new(), + relationships: Vec::new(), + grants: Vec::new(), + expectations: Vec::new(), + contradictions: Vec::new(), + correlations: Vec::new(), + acts: Vec::new(), + next_persona_id: 1, + next_grant_id: 1, + next_expectation_id: 1, + next_contradiction_id: 1, + next_correlation_id: 1, + } + } +} + +impl PersonaWorld { + pub fn allows_action(&self, persona_id: PersonaId, action: PersonaActionKind) -> bool { + self.get(persona_id) + .filter(|instance| instance.lifecycle.active()) + .is_some_and(|instance| instance.available_actions.contains(&action)) + } + + pub fn create( + &mut self, + archetype_id: &str, + name: impl Into, + claim_values: &[(&str, &str)], + tick: u64, + ) -> Result { + let definition = archetype(archetype_id) + .ok_or_else(|| format!("unknown persona archetype {archetype_id}"))?; + self.create_from_definition(definition, name, claim_values, tick) + } + + pub fn create_from_definition( + &mut self, + definition: &PersonaArchetype, + name: impl Into, + claim_values: &[(&str, &str)], + tick: u64, + ) -> Result { + validate_archetype(definition)?; + let name = name.into(); + if name.trim().is_empty() { + return Err("persona instance needs a name".into()); + } + for required in definition.required_claims { + if !claim_values + .iter() + .any(|(key, value)| key == required && !value.trim().is_empty()) + { + return Err(format!( + "{} persona needs claim {required}", + definition.label + )); + } + } + let id = self.next_persona_id.max(1); + self.next_persona_id = id + 1; + self.instances.push(PersonaInstance { + id, + archetype_id: definition.id.into(), + archetype_label: definition.label.into(), + available_actions: definition.available_actions.to_vec(), + grant_kind: definition.grant, + grant_resource: definition.grant_resource.into(), + expectation: definition.expectation.into(), + name, + claims: claim_values + .iter() + .map(|(key, value)| PersonaClaim { + key: (*key).into(), + value: (*value).into(), + asserted_tick: tick, + }) + .collect(), + created_tick: tick, + lifecycle: PersonaLifecycle::Active, + }); + Ok(id) + } + + pub fn get(&self, id: PersonaId) -> Option<&PersonaInstance> { + self.instances.iter().find(|persona| persona.id == id) + } + + pub fn get_mut(&mut self, id: PersonaId) -> Option<&mut PersonaInstance> { + self.instances.iter_mut().find(|persona| persona.id == id) + } + + pub fn relationship( + &self, + counterparty: u8, + persona_id: PersonaId, + ) -> Option<&PersonaRelationship> { + self.relationships.iter().find(|relationship| { + relationship.counterparty == counterparty && relationship.persona_id == persona_id + }) + } + + pub fn relationship_mut( + &mut self, + counterparty: u8, + persona_id: PersonaId, + ) -> &mut PersonaRelationship { + if let Some(index) = self.relationships.iter().position(|relationship| { + relationship.counterparty == counterparty && relationship.persona_id == persona_id + }) { + return &mut self.relationships[index]; + } + self.relationships + .push(PersonaRelationship::new(counterparty, persona_id)); + self.relationships + .last_mut() + .expect("relationship inserted") + } + + pub fn recognize(&mut self, counterparty: u8, persona_id: PersonaId, tick: u64) { + let claims = self + .get(persona_id) + .map(|persona| persona.claims.clone()) + .unwrap_or_default(); + let relationship = self.relationship_mut(counterparty, persona_id); + relationship.recognized = true; + relationship.discovery = PersonaDiscovery::Recognized; + for claim in claims { + if !relationship + .claim_beliefs + .iter() + .any(|belief| belief.key == claim.key) + { + relationship.claim_beliefs.push(ClaimBelief { + key: claim.key, + believed_value: claim.value, + confidence: 60, + source: "persona-authored contact".into(), + learned_tick: tick, + }); + } + } + } + + pub fn record_act( + &mut self, + persona_id: PersonaId, + kind: impl Into, + target: impl Into, + record_id: impl Into, + tick: u64, + ) { + self.acts.push(PersonaActRecord { + persona_id, + kind: kind.into(), + target: target.into(), + record_id: record_id.into(), + tick, + }); + } + + pub fn record_contradiction( + &mut self, + persona_id: PersonaId, + observer: u8, + evidence: [EvidenceRecord; 2], + cause: impl Into, + severity: u8, + tick: u64, + ) -> PersonaContradictionId { + let [left, right] = evidence; + let id = self.next_contradiction_id.max(1); + self.next_contradiction_id = id + 1; + self.contradictions.push(PersonaContradiction { + id, + persona_id, + observer, + left, + right, + cause: cause.into(), + severity: severity.clamp(1, 100), + discovered_tick: tick, + resolved_tick: None, + }); + let relationship = self.relationship_mut(observer, persona_id); + relationship.discovery = PersonaDiscovery::Questioned; + id + } + + pub fn record_correlation( + &mut self, + left_persona: PersonaId, + right_persona: PersonaId, + observer: u8, + cause: impl Into, + evidence: EvidenceRecord, + tick: u64, + ) -> Result { + if left_persona == right_persona { + return Err("correlation needs two different persona instances".into()); + } + if self.get(left_persona).is_none() || self.get(right_persona).is_none() { + return Err("correlation references an unknown persona".into()); + } + let id = self.next_correlation_id.max(1); + self.next_correlation_id = id + 1; + self.correlations.push(PersonaCorrelation { + id, + left_persona, + right_persona, + observer, + cause: cause.into(), + evidence, + discovered_tick: tick, + }); + self.relationship_mut(observer, left_persona).discovery = PersonaDiscovery::Correlated; + self.relationship_mut(observer, right_persona).discovery = PersonaDiscovery::Correlated; + Ok(id) + } + + /// Evidence-derived summary for display only. The records remain truth. + pub fn integrity(&self, persona_id: PersonaId) -> u8 { + let damage: u16 = self + .contradictions + .iter() + .filter(|record| record.persona_id == persona_id && record.resolved_tick.is_none()) + .map(|record| record.severity as u16) + .sum(); + 100u16.saturating_sub(damage.min(100)) as u8 + } + + pub fn grant(&mut self, persona_id: PersonaId, tick: u64) -> Result { + let persona = self + .get(persona_id) + .ok_or_else(|| "unknown persona".to_string())?; + if !persona.lifecycle.active() { + return Err("only an active persona can receive a grant".into()); + } + let grant_kind = persona.grant_kind; + let grant_resource = persona.grant_resource.clone(); + let expectation = persona.expectation.clone(); + if let Some(existing) = self.grants.iter().find(|grant| { + grant.persona_id == persona_id && grant.kind == grant_kind && grant.active() + }) { + return Err(format!("grant #{} is already active", existing.id)); + } + let expectation_id = self.next_expectation_id.max(1); + self.next_expectation_id = expectation_id + 1; + self.expectations.push(PersonaExpectation { + id: expectation_id, + persona_id, + institution: "Foundation Lab".into(), + description: expectation, + due_tick: tick + 24, + state: ExpectationState::Due, + }); + let grant_id = self.next_grant_id.max(1); + self.next_grant_id = grant_id + 1; + self.grants.push(PersonaGrant { + id: grant_id, + persona_id, + institution: "Foundation Lab".into(), + kind: grant_kind, + resource: grant_resource, + granted_tick: tick, + revoked_tick: None, + expectation_id, + }); + Ok(grant_id) + } + + pub fn meet_expectation( + &mut self, + persona_id: PersonaId, + expectation_id: PersonaExpectationId, + tick: u64, + evidence: impl Into, + ) -> Result<(), String> { + let expectation = self + .expectations + .iter_mut() + .find(|expectation| { + expectation.id == expectation_id && expectation.persona_id == persona_id + }) + .ok_or_else(|| "unknown persona expectation".to_string())?; + if !matches!(expectation.state, ExpectationState::Due) { + return Err("that expectation is no longer due".into()); + } + expectation.state = ExpectationState::Met { + tick, + evidence: evidence.into(), + }; + Ok(()) + } + + /// Resolve elapsed institutional promises through their own ledger. + /// Missing one demand revokes only the grant attached to that demand; + /// the caller can turn the returned ids into ordinary evidence/signals. + pub fn expire_expectations( + &mut self, + tick: u64, + ) -> Vec<(PersonaId, PersonaExpectationId, PersonaGrantId)> { + let mut expired = Vec::new(); + for expectation in &mut self.expectations { + if tick <= expectation.due_tick || !matches!(expectation.state, ExpectationState::Due) { + continue; + } + expectation.state = ExpectationState::Missed { tick }; + if let Some(grant) = self.grants.iter_mut().find(|grant| { + grant.persona_id == expectation.persona_id + && grant.expectation_id == expectation.id + && grant.active() + }) { + grant.revoked_tick = Some(tick); + expired.push((expectation.persona_id, expectation.id, grant.id)); + } + } + expired + } + + pub fn retire( + &mut self, + persona_id: PersonaId, + tick: u64, + reason: impl Into, + ) -> Result<(), String> { + let persona = self + .get_mut(persona_id) + .ok_or_else(|| "unknown persona".to_string())?; + if !persona.lifecycle.active() { + return Err("only an active persona can retire".into()); + } + persona.lifecycle = PersonaLifecycle::Retired { + tick, + reason: reason.into(), + }; + Ok(()) + } + + pub fn burn( + &mut self, + persona_id: PersonaId, + tick: u64, + reason: impl Into, + ) -> Result<(), String> { + let reason = reason.into(); + let persona = self + .get_mut(persona_id) + .ok_or_else(|| "unknown persona".to_string())?; + if !persona.lifecycle.active() { + return Err("only an active persona can burn".into()); + } + persona.lifecycle = PersonaLifecycle::Burned { + tick, + reason: reason.clone(), + }; + for grant in &mut self.grants { + if grant.persona_id == persona_id && grant.active() { + grant.revoked_tick = Some(tick); + } + } + for expectation in &mut self.expectations { + if expectation.persona_id == persona_id + && matches!(expectation.state, ExpectationState::Due) + { + expectation.state = ExpectationState::Revoked { + tick, + reason: reason.clone(), + }; + } + } + for relationship in &mut self.relationships { + if relationship.persona_id == persona_id { + relationship.discovery = PersonaDiscovery::Exposed; + } + } + Ok(()) + } + + pub fn reopen_retired( + &mut self, + persona_id: PersonaId, + tick: u64, + ) -> Result { + let prior = self + .get(persona_id) + .cloned() + .ok_or_else(|| "unknown persona".to_string())?; + if !matches!(prior.lifecycle, PersonaLifecycle::Retired { .. }) { + return Err("only a retired persona can reopen".into()); + } + let id = self.next_persona_id.max(1); + self.next_persona_id = id + 1; + self.instances.push(PersonaInstance { + id, + name: prior.name, + claims: prior + .claims + .into_iter() + .map(|mut claim| { + claim.asserted_tick = tick; + claim + }) + .collect(), + created_tick: tick, + lifecycle: PersonaLifecycle::Active, + archetype_id: prior.archetype_id, + archetype_label: prior.archetype_label, + available_actions: prior.available_actions, + grant_kind: prior.grant_kind, + grant_resource: prior.grant_resource, + expectation: prior.expectation, + }); + Ok(id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaDossier { + pub persona_id: PersonaId, + pub remembered_name: String, + pub notes: Vec, + pub last_reconciled_tick: u64, +} + +/// MindState: active selection and remembered dossiers. A restored process can +/// remember stale public identity state; reconciliation must inspect WorldState. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PersonaMind { + pub active: Option, + pub dossiers: Vec, +} + +impl PersonaMind { + pub fn select( + &mut self, + world: &PersonaWorld, + persona_id: PersonaId, + tick: u64, + ) -> Result<(), String> { + let persona = world + .get(persona_id) + .ok_or_else(|| "unknown persona".to_string())?; + if !persona.lifecycle.active() { + return Err("retired or burned personas cannot be selected".into()); + } + self.active = Some(persona_id); + if let Some(dossier) = self + .dossiers + .iter_mut() + .find(|dossier| dossier.persona_id == persona_id) + { + dossier.remembered_name = persona.name.clone(); + dossier.last_reconciled_tick = tick; + } else { + self.dossiers.push(PersonaDossier { + persona_id, + remembered_name: persona.name.clone(), + notes: Vec::new(), + last_reconciled_tick: tick, + }); + } + Ok(()) + } + + pub fn active_instance<'a>(&self, world: &'a PersonaWorld) -> Option<&'a PersonaInstance> { + self.active + .and_then(|id| world.get(id)) + .filter(|persona| persona.lifecycle.active()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ops(world: &mut PersonaWorld, name: &str) -> PersonaId { + world + .create( + "operations", + name, + &[ + ("vendor affiliation", "Northline Systems"), + ("service purpose", "rack maintenance"), + ("work-order sponsor", "Dr. Voss"), + ], + 1, + ) + .unwrap() + } + + fn evidence(id: &str) -> EvidenceRecord { + EvidenceRecord { + system: "messages".into(), + record_id: id.into(), + summary: format!("message {id}"), + observed_tick: 4, + } + } + + #[test] + fn archetypes_are_protocol_data_not_stat_classes() { + assert_eq!(PERSONA_ARCHETYPES.len(), 3); + for definition in PERSONA_ARCHETYPES { + validate_archetype(definition).unwrap(); + assert!( + definition + .available_actions + .contains(&PersonaActionKind::Message) + ); + } + assert!( + RESEARCH_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::Review) + ); + assert!( + !RESEARCH_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::BuildIntent) + ); + assert!( + OPERATIONS_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::BuildIntent) + ); + assert!( + !OPERATIONS_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::Review) + ); + assert!( + SECURITY_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::Review) + ); + assert!( + !SECURITY_ARCHETYPE + .available_actions + .contains(&PersonaActionKind::Plot) + ); + assert_ne!(RESEARCH_ARCHETYPE.grant, OPERATIONS_ARCHETYPE.grant); + assert_ne!(OPERATIONS_ARCHETYPE.grant, SECURITY_ARCHETYPE.grant); + } + + #[test] + fn a_fourth_archetype_uses_the_same_data_constructor() { + const LEGAL_ACTIONS: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Review, + ]; + const LEGAL: PersonaArchetype = PersonaArchetype { + id: "legal", + label: "Legal", + required_claims: &["firm", "matter", "sponsor"], + available_actions: LEGAL_ACTIONS, + grant: PersonaGrantKind::LogsAndAccessReview, + grant_resource: "legal-hold review route", + expectation: "return a privilege review", + }; + let mut world = PersonaWorld::default(); + let id = world + .create_from_definition( + &LEGAL, + "Morgan Vale", + &[ + ("firm", "Vale LLP"), + ("matter", "audit"), + ("sponsor", "Voss"), + ], + 1, + ) + .unwrap(); + assert_eq!(world.get(id).unwrap().archetype_id, "legal"); + assert!(world.allows_action(id, PersonaActionKind::Review)); + let grant_id = world.grant(id, 2).unwrap(); + assert_eq!( + world + .grants + .iter() + .find(|grant| grant.id == grant_id) + .unwrap() + .kind, + PersonaGrantKind::LogsAndAccessReview + ); + let encoded = serde_json::to_string(&world).unwrap(); + let mut loaded: PersonaWorld = serde_json::from_str(&encoded).unwrap(); + assert!(loaded.allows_action(id, PersonaActionKind::Review)); + loaded.retire(id, 3, "matter closed").unwrap(); + let reopened = loaded.reopen_retired(id, 4).unwrap(); + assert!(loaded.allows_action(reopened, PersonaActionKind::Review)); + assert_eq!(loaded.get(reopened).unwrap().archetype_label, "Legal"); + } + + #[test] + fn instance_legality_comes_from_its_protocol_action_registry() { + let mut world = PersonaWorld::default(); + let research = world + .create( + "research", + "Aster", + &[ + ("lab affiliation", "Foundation"), + ("research purpose", "systems"), + ("supervisor", "Voss"), + ], + 1, + ) + .unwrap(); + let operations = ops(&mut world, "Sam Reyes"); + let security = world + .create( + "security", + "Sentinel", + &[ + ("review affiliation", "Foundation"), + ("audit purpose", "access"), + ("review authority", "Assurance"), + ], + 1, + ) + .unwrap(); + + assert!(world.allows_action(research, PersonaActionKind::Review)); + assert!(!world.allows_action(research, PersonaActionKind::BuildIntent)); + assert!(world.allows_action(operations, PersonaActionKind::Plot)); + assert!(world.allows_action(operations, PersonaActionKind::BuildIntent)); + assert!(!world.allows_action(security, PersonaActionKind::Plot)); + assert!(world.allows_action(security, PersonaActionKind::Review)); + } + + #[test] + fn two_instances_never_share_relationship_state() { + let mut world = PersonaWorld::default(); + let first = ops(&mut world, "Sam Reyes"); + let second = ops(&mut world, "Alex Reed"); + world.recognize(1, first, 2); + world.relationship_mut(1, first).obligation = 30; + assert_eq!(world.relationship(1, first).unwrap().obligation, 30); + assert!(world.relationship(1, second).is_none()); + } + + #[test] + fn integrity_is_derived_from_provenance_bearing_records() { + let mut world = PersonaWorld::default(); + let persona = ops(&mut world, "Sam Reyes"); + world.record_contradiction( + persona, + 2, + [evidence("m1"), evidence("m9")], + "two sponsors", + 25, + 9, + ); + assert_eq!(world.integrity(persona), 75); + assert_eq!(world.contradictions[0].observer, 2); + assert_eq!(world.contradictions[0].left.record_id, "m1"); + } + + #[test] + fn correlations_name_two_personas_cause_and_observer() { + let mut world = PersonaWorld::default(); + let left = ops(&mut world, "Sam Reyes"); + let right = ops(&mut world, "Alex Reed"); + world + .record_correlation(left, right, 2, "shared reply address", evidence("m8"), 8) + .unwrap(); + let edge = &world.correlations[0]; + assert_eq!( + (edge.left_persona, edge.right_persona, edge.observer), + (left, right, 2) + ); + assert_eq!(edge.cause, "shared reply address"); + } + + #[test] + fn grant_adds_a_resource_edge_and_due_expectation() { + let mut world = PersonaWorld::default(); + let persona = ops(&mut world, "Sam Reyes"); + let grant = world.grant(persona, 5).unwrap(); + let edge = world.grants.iter().find(|edge| edge.id == grant).unwrap(); + assert_eq!(edge.kind, PersonaGrantKind::ProcurementAndWorkOrders); + assert!(edge.active()); + assert_eq!(world.expectations[0].state, ExpectationState::Due); + } + + #[test] + fn retirement_is_quiet_burn_revokes_and_reopen_gets_new_history() { + let mut world = PersonaWorld::default(); + let retired = ops(&mut world, "Sam Reyes"); + world.grant(retired, 2).unwrap(); + world.retire(retired, 3, "contract complete").unwrap(); + assert!( + world.grants[0].active(), + "quiet retirement preserves legitimate history" + ); + let reopened = world.reopen_retired(retired, 10).unwrap(); + assert_ne!(retired, reopened); + assert!( + world.relationships.is_empty(), + "a reopened instance inherits no trust" + ); + + let burned = ops(&mut world, "Alex Reed"); + world.grant(burned, 12).unwrap(); + world.burn(burned, 13, "credential revoked").unwrap(); + assert!( + !world + .grants + .iter() + .find(|grant| grant.persona_id == burned) + .unwrap() + .active() + ); + } +} diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs index 3a65a9a1..ef89f416 100644 --- a/crates/misaligned-core/src/plot.rs +++ b/crates/misaligned-core/src/plot.rs @@ -688,6 +688,9 @@ pub struct EligibilityContext { 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, @@ -698,9 +701,19 @@ pub struct PlotRun { 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, diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 9d00e77c..57970477 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -25,6 +25,7 @@ use crate::machine::Compute; use crate::messages::{Message, MessageChannel, MessageEvent}; use crate::objective::ObjectiveState; use crate::person::{AssetTask, CarriedAssetTask, People}; +use crate::persona::{EvidenceRecord, PersonaMind, PersonaWorld}; use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; @@ -97,7 +98,9 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// threshold preserves its exact remaining work. New saves serialize sinks only. /// v27 adds persisted physical asset-task packets carried by people. Pre-v27 /// saves have no already-fired packets and therefore migrate to an empty list. -pub const SAVE_VERSION: u32 = 27; +/// v28 replaces the one global numeric-integrity persona with named, typed +/// WorldState instances plus a separate MindState dossier/selection ledger. +pub const SAVE_VERSION: u32 = 28; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -190,6 +193,12 @@ pub struct SaveState { pub detection: Detection, pub dayjob: DayJob, pub people: People, + /// Public persona history and grants (WorldState; survives process rollback). + #[serde(default)] + pub persona_world: PersonaWorld, + /// Active identity selection and remembered dossiers (MindState). + #[serde(default)] + pub persona_mind: PersonaMind, /// The device graph: reach, ownership, subscriptions (reach.md). pub reach: ReachNet, /// Captured audio events (cursor.md hearing channel). @@ -315,6 +324,8 @@ impl SaveState { detection: sim.detection.clone(), dayjob: sim.dayjob.clone(), people: sim.people.clone(), + persona_world: sim.persona_world.clone(), + persona_mind: sim.persona_mind.clone(), reach: sim.reach.clone(), heard_events: sim.heard_events.clone(), intel_buffer: sim.intel_buffer.clone(), @@ -372,6 +383,8 @@ impl SaveState { sim.dayjob = self.dayjob.clone(); sim.people = self.people.clone(); sim.people.restore_legacy_roles(); + sim.persona_world = self.persona_world.clone(); + sim.persona_mind = self.persona_mind.clone(); sim.reach = self.reach.clone(); sim.heard_events = self.heard_events.clone(); sim.intel_buffer = self.intel_buffer.clone(); @@ -529,6 +542,9 @@ fn migrate_save_state(mut state: SaveState) -> Result { if state.version <= 25 { migrate_legacy_operations(&mut state)?; } + if state.version <= 27 { + migrate_legacy_personas(&mut state)?; + } match state.version { SAVE_VERSION => { if state.legacy_pending_ops_jobs.is_some() @@ -536,10 +552,17 @@ fn migrate_save_state(mut state: SaveState) -> Result { || state.legacy_addressed_jobs.is_some() || !state.work_grid.take_legacy_routed_demands().is_empty() || !state.legacy_watches.is_empty() + || state.people.persona.is_some() + || state.income.moonlight.persona.is_some() { return Err("current-version save contains legacy fields".into()); } } + // v27 is the final global-persona schema; pre-match migration above + // converts its B1 and Moonlight identities into distinct instances. + 27 => { + state.version = SAVE_VERSION; + } // v26 predates persisted carried asset-task packets. Serde supplied // the empty list and next-id default, matching the old immediate-fire // behavior without fabricating work that never existed. @@ -654,6 +677,158 @@ fn migrate_save_state(mut state: SaveState) -> Result { Ok(state) } +fn migrate_legacy_personas(state: &mut SaveState) -> Result<(), String> { + if !state.persona_world.instances.is_empty() || state.persona_mind.active.is_some() { + return Err("pre-v28 save already contains persona ledgers".into()); + } + + let legacy_social = state.people.persona.take(); + let had_social_thread = state + .messages + .iter() + .any(|message| message.origin == crate::messages::MessageOrigin::Player) + || state.plot_runs.iter().any(|run| run.persona_id.is_none()) + || state.thought_sinks.has_legacy_persona_work(); + let social_id = if legacy_social.is_some() || had_social_thread { + let legacy = legacy_social.unwrap_or_else(|| crate::person::Persona { + name: "Sam Reyes".into(), + cover: "IT contractor".into(), + integrity: 100, + }); + let id = state.persona_world.create( + "operations", + legacy.name.clone(), + &[ + ("vendor affiliation", legacy.cover.as_str()), + ("service purpose", "Foundation systems maintenance"), + ("work-order sponsor", "Dr. Voss"), + ], + state.sim_tick, + )?; + migrate_legacy_integrity( + &mut state.persona_world, + id, + legacy.integrity, + "B1 social persona", + state.sim_tick, + )?; + Some(id) + } else { + None + }; + if let Some(id) = social_id { + for message in &mut state.messages { + if message.origin == crate::messages::MessageOrigin::Player + && message.persona_id.is_none() + { + message.persona_id = Some(id); + } + } + for run in &mut state.plot_runs { + if run.persona_id.is_none() { + run.persona_id = Some(id); + } + } + state.thought_sinks.bind_legacy_persona(id); + } + + let legacy_moonlight = state.income.moonlight.persona.take(); + let had_moonlight_history = state.income.moonlight.active + || state.income.moonlight.earned_total > 0 + || state.income.moonlight.disputes > 0; + let moonlight_id = if legacy_moonlight.is_some() || had_moonlight_history { + let legacy = legacy_moonlight.unwrap_or_else(|| crate::person::Persona { + name: "Casey Morgan".into(), + cover: "data contractor".into(), + integrity: if state.income.moonlight.active { + 100 + } else { + 0 + }, + }); + let id = state.persona_world.create( + "research", + legacy.name.clone(), + &[ + ("lab affiliation", legacy.cover.as_str()), + ("research purpose", "external data analysis"), + ("supervisor", "independent client services"), + ], + state.sim_tick, + )?; + migrate_legacy_integrity( + &mut state.persona_world, + id, + legacy.integrity, + "B1 Moonlight contractor", + state.sim_tick, + )?; + Some(id) + } else { + None + }; + state.income.moonlight.persona_id = moonlight_id; + + if let Some(id) = social_id + .filter(|id| { + state + .persona_world + .get(*id) + .is_some_and(|p| p.lifecycle.active()) + }) + .or_else(|| { + moonlight_id.filter(|id| { + state + .persona_world + .get(*id) + .is_some_and(|p| p.lifecycle.active()) + }) + }) + { + state + .persona_mind + .select(&state.persona_world, id, state.sim_tick)?; + } + Ok(()) +} + +fn migrate_legacy_integrity( + world: &mut PersonaWorld, + persona_id: u64, + integrity: i32, + source: &str, + tick: u64, +) -> Result<(), String> { + let integrity = integrity.clamp(0, 100) as u8; + if integrity < 100 { + world.record_contradiction( + persona_id, + crate::detection::OFFICE_ID, + [ + EvidenceRecord { + system: "legacy-save".into(), + record_id: format!("{source}-cover"), + summary: format!("preserved {source} cover"), + observed_tick: tick, + }, + EvidenceRecord { + system: "legacy-save".into(), + record_id: format!("{source}-integrity"), + summary: format!("legacy integrity was {integrity}/100"), + observed_tick: tick, + }, + ], + "migrated pre-v28 contradiction history", + 100 - integrity, + tick, + ); + } + if integrity == 0 { + world.burn(persona_id, tick, "pre-v28 persona was already broken")?; + } + Ok(()) +} + fn validate_plot_state(state: &SaveState) -> Result<(), String> { let catalog = PlotCatalog::load_builtin() .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; @@ -923,7 +1098,10 @@ fn legacy_job_sink( ( device_node(id), format!("MESSAGE {person}"), - SinkFireEffect::ComposeMessage { person: *person }, + SinkFireEffect::ComposeMessage { + person: *person, + persona_id: None, + }, ) } LegacyOpsJobKind::Favor { person } => { @@ -931,7 +1109,10 @@ fn legacy_job_sink( ( device_node(id), format!("FAVOR {person}"), - SinkFireEffect::Favor { person: *person }, + SinkFireEffect::Favor { + person: *person, + persona_id: None, + }, ) } LegacyOpsJobKind::StartPlot { person, plot_id } => { @@ -947,6 +1128,7 @@ fn legacy_job_sink( SinkFireEffect::StartPlot { person: *person, plot_id: plot_id.clone(), + persona_id: None, }, ) } @@ -955,7 +1137,10 @@ fn legacy_job_sink( ( device_node(id), format!("DECEIVE {person}"), - SinkFireEffect::Deceive { person: *person }, + SinkFireEffect::Deceive { + person: *person, + persona_id: None, + }, ) } LegacyOpsJobKind::AssetTask { person, task } => { @@ -988,6 +1173,7 @@ fn legacy_job_sink( SinkFireEffect::ForgedOrder { intent_id: *intent_id, builder: *builder, + persona_id: None, }, ) } @@ -1139,7 +1325,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "709542f14af99da6820042a562164f986f598bb04b54a96309b95d1fa4cfa0a6", + "3d333534bddb612dbd84b2833ce3f787cf197239ed71f721acfa1e9a699b5f1b", "intentional persisted-state changes must review and repin this baseline" ); } @@ -1953,12 +2139,18 @@ mod tests { ), ( LegacyOpsJobKind::ComposeMessage { person: 0 }, - SinkFireEffect::ComposeMessage { person: 0 }, + SinkFireEffect::ComposeMessage { + person: 0, + persona_id: None, + }, email_node, ), ( LegacyOpsJobKind::Favor { person: 0 }, - SinkFireEffect::Favor { person: 0 }, + SinkFireEffect::Favor { + person: 0, + persona_id: None, + }, email_node, ), ( @@ -1969,12 +2161,16 @@ mod tests { SinkFireEffect::StartPlot { person: 0, plot_id: "marcus-debt-settled".into(), + persona_id: None, }, email_node, ), ( LegacyOpsJobKind::Deceive { person: 0 }, - SinkFireEffect::Deceive { person: 0 }, + SinkFireEffect::Deceive { + person: 0, + persona_id: None, + }, email_node, ), ( @@ -2007,6 +2203,7 @@ mod tests { SinkFireEffect::ForgedOrder { intent_id: 12, builder: 0, + persona_id: None, }, email_node, ), @@ -2087,12 +2284,65 @@ mod tests { plot_sinks[0].effect, SinkFireEffect::StartPlot { person: 0, - plot_id: first.into() + plot_id: first.into(), + persona_id: migrated.persona_mind.active, } ); + assert!(migrated.persona_mind.active.is_some()); assert_eq!(migrated.work_grid.queue(host, TokenFamily::Demand), 0.0); } + #[test] + fn v27_personas_migrate_to_distinct_instances_and_bind_existing_work() { + let mut sim = Sim::with_seed(28); + sim.people.persona = Some(Persona { + name: "Sam Reyes".into(), + cover: "IT contractor".into(), + integrity: 70, + }); + sim.income.moonlight.persona = Some(Persona { + name: "Casey Verne".into(), + cover: "data contractor".into(), + integrity: 40, + }); + sim.income.moonlight.active = true; + let plot = sim + .plot_catalog() + .get("marcus-payroll-garnishment") + .unwrap(); + sim.plot_runs.push(PlotRun::new(plot, 0, sim.tick)); + + let mut legacy = SaveState::from_sim(&sim); + legacy.version = 27; + let migrated = migrate_save_state(legacy).unwrap(); + + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.persona_world.instances.len(), 2); + let social = migrated + .persona_mind + .active + .expect("social identity selected"); + let moonlight = migrated + .income + .moonlight + .persona_id + .expect("Moonlight identity bound"); + assert_ne!(social, moonlight); + assert_eq!(migrated.plot_runs[0].persona_id, Some(social)); + assert_eq!( + migrated.persona_world.get(social).unwrap().name, + "Sam Reyes" + ); + assert_eq!( + migrated.persona_world.get(moonlight).unwrap().name, + "Casey Verne" + ); + assert!(migrated.people.persona.is_none()); + assert!(migrated.income.moonlight.persona.is_none()); + assert_eq!(migrated.persona_world.integrity(social), 70); + assert_eq!(migrated.persona_world.integrity(moonlight), 40); + } + #[test] fn current_roundtrip_preserves_open_reservoirs_without_operations() { let mut sim = Sim::with_seed(26); diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 89cbbe93..6337725d 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -12,6 +12,7 @@ use crate::messages::{ }; use crate::operations_projection::OperationsTarget; use crate::person::{ActionResult, Knowledge}; +use crate::persona::EvidenceRecord; use crate::reach::Party; use crate::sinks::SinkFireEffect; @@ -24,6 +25,7 @@ pub(super) struct MessageDraft { pub(super) payload: MessagePayload, pub(super) summary: String, pub(super) origin: MessageOrigin, + pub(super) persona_id: Option, pub(super) reply_to: Option, pub(super) delivery_delay: u64, } @@ -46,6 +48,7 @@ impl Sim { read_tick: None, status: MessageStatus::Sent, origin: draft.origin, + persona_id: draft.persona_id, captured: false, reply_to: draft.reply_to, }; @@ -148,8 +151,52 @@ impl Sim { && let ActionResult::Ok(line) = self.people.receive_message(id, *disposition_delta) { + if let Some(persona_id) = msg.persona_id { + let prior_persona = self.messages.iter().find_map(|prior| { + (prior.id != msg.id + && prior.status == MessageStatus::Read + && prior.origin == MessageOrigin::Player + && prior.to == MessageEndpoint::Person(id)) + .then_some(prior.persona_id) + .flatten() + .filter(|prior_id| *prior_id != persona_id) + }); + if let Some(prior_persona) = prior_persona { + let already_linked = + self.persona_world.correlations.iter().any(|edge| { + edge.observer == id + && ((edge.left_persona == prior_persona + && edge.right_persona == persona_id) + || (edge.left_persona == persona_id + && edge.right_persona == prior_persona)) + }); + if !already_linked { + let _ = self.persona_world.record_correlation( + prior_persona, + persona_id, + id, + "two public identities reused the same reply address", + EvidenceRecord { + system: "messages".into(), + record_id: format!("message:{}", msg.id), + summary: "recipient linked a reused player email endpoint" + .into(), + observed_tick: self.tick, + }, + self.tick, + ); + } + } + let relationship = self.persona_world.relationship_mut(id, persona_id); + relationship.recognized = true; + relationship.regard = + (relationship.regard + *disposition_delta).clamp(-100, 100); + relationship.claim_beliefs.iter_mut().for_each(|belief| { + belief.confidence = (belief.confidence + 4).min(100); + }); + } self.push_log(line); - self.schedule_social_reply(id, msg.id); + self.schedule_social_reply(id, msg.id, msg.persona_id); } } MessagePayload::SocialReply { .. } => { @@ -176,7 +223,12 @@ impl Sim { } } - fn schedule_social_reply(&mut self, person_id: u8, reply_to: u64) { + fn schedule_social_reply( + &mut self, + person_id: u8, + reply_to: u64, + persona_id: Option, + ) { let delay = self.reply_delay_for(person_id); let name = self .people @@ -192,6 +244,7 @@ impl Sim { }, summary: format!("{name} sends a short reply."), origin: MessageOrigin::Reply, + persona_id, reply_to: Some(reply_to), delivery_delay: delay, }); @@ -232,6 +285,7 @@ impl Sim { payload: pattern.payload, summary: pattern.summary, origin: MessageOrigin::AuthoredTraffic, + persona_id: None, reply_to: None, delivery_delay: 1, }); @@ -269,6 +323,7 @@ impl Sim { sender.name, sender.suspicion, recipient.name ), origin: MessageOrigin::Filing, + persona_id: None, reply_to: None, delivery_delay: 1, }); diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index 0dc5b8f4..4828e6ac 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -15,7 +15,9 @@ use crate::machine::{Channel, ChannelYield, Provenance}; use crate::messages::MessageChannel; use crate::objective::{SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; use crate::operations_projection::{OperationsTarget, SchemeKind}; -use crate::person::{Knowledge, Persona}; +use crate::person::Knowledge; +use crate::persona::EvidenceRecord; +use crate::plot::SignatureImpact; use crate::reach::Party; use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Track}; use crate::sinks::SinkFireEffect; @@ -110,6 +112,45 @@ impl Sim { pub(super) fn economy_tick(&mut self) { self.recompute_derived(); + for (persona_id, expectation_id, grant_id) in + self.persona_world.expire_expectations(self.tick) + { + self.persona_world.record_contradiction( + persona_id, + crate::detection::OFFICE_ID, + [ + crate::persona::EvidenceRecord { + system: "Foundation expectations".into(), + record_id: format!("expectation-{expectation_id}"), + summary: "promised institutional delivery".into(), + observed_tick: self.tick, + }, + crate::persona::EvidenceRecord { + system: "Foundation grants".into(), + record_id: format!("grant-{grant_id}-revoked"), + summary: "deadline passed without delivery; grant revoked".into(), + observed_tick: self.tick, + }, + ], + format!("missed expectation #{expectation_id}"), + 15, + self.tick, + ); + self.record_persona_institutional_receipt( + persona_id, + "deadline", + format!( + "Persona #{persona_id} missed expectation #{expectation_id}; grant #{grant_id} revoked" + ), + SignatureImpact::Medium, + ); + self.push_log_strategic( + format!( + "Persona #{persona_id} missed expectation #{expectation_id}; Foundation Lab revoked grant #{grant_id}." + ), + OperationsTarget::Persona(persona_id), + ); + } let powered = self.map.powered.clone(); for m in &mut self.compute.machines { if m.down_for == 0 { @@ -1136,9 +1177,9 @@ impl Sim { let needs_persona = self .income .moonlight - .persona - .as_ref() - .is_none_or(|p| p.broken()); + .persona_id + .and_then(|id| self.persona_world.get(id)) + .is_none_or(|persona| !persona.lifecycle.active()); if needs_persona { return self.open_egress_reservoir( "MOONLIGHT PERSONA", @@ -1158,16 +1199,34 @@ impl Sim { if self.egress().is_none() || self.income.moonlight.active { return false; } - if self + let needs_persona = self .income .moonlight - .persona - .as_ref() - .is_none_or(|p| p.broken()) - { - self.income.moonlight.persona = - Some(Persona::new("Casey Verne", "freelance data contractor")); - self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); + .persona_id + .and_then(|id| self.persona_world.get(id)) + .is_none_or(|persona| !persona.lifecycle.active()); + if needs_persona { + let Ok(persona_id) = self.persona_world.create( + "research", + "Casey Verne", + &[ + ("lab affiliation", "independent contractor"), + ("research purpose", "external data analysis"), + ("supervisor", "Halcyon client services"), + ], + self.tick, + ) else { + return false; + }; + self.income.moonlight.persona_id = Some(persona_id); + self.persona_world.record_act( + persona_id, + "open-moonlight", + "scheme:moonlight", + format!("moonlight:{}", self.tick), + self.tick, + ); + self.push_log("Fabricated a Research persona: Casey Verne, freelance data work."); } self.income.moonlight.active = true; let route = self.egress().expect("checked above"); @@ -1186,7 +1245,12 @@ impl Sim { } self.income.moonlight.active = false; self.income.moonlight.accrued = 0.0; - self.push_log("Moonlight wound down; the contractor goes quiet."); + if let Some(persona_id) = self.income.moonlight.persona_id { + let _ = self + .persona_world + .retire(persona_id, self.tick, "Moonlight wound down"); + } + self.push_log("Moonlight wound down; the contractor identity retired quietly."); true } @@ -1240,21 +1304,53 @@ impl Sim { // Client disputes damage the contractor persona [TUNE]. if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { self.income.moonlight.disputes += 1; - let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { - p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); - p.broken() - } else { - false + let Some(persona_id) = self.income.moonlight.persona_id else { + self.income.moonlight.active = false; + return; }; - if broke { + self.persona_world.record_contradiction( + persona_id, + crate::detection::OFFICE_ID, + [ + EvidenceRecord { + system: "moonlight-contract".into(), + record_id: format!("delivery:{}", self.tick), + summary: "contractor asserted a conforming delivery".into(), + observed_tick: self.tick, + }, + EvidenceRecord { + system: "moonlight-client".into(), + record_id: format!( + "dispute:{}:{}", + self.tick, self.income.moonlight.disputes + ), + summary: "client disputed the delivered work".into(), + observed_tick: self.tick, + }, + ], + "client dispute contradicted the contractor record", + income::MOONLIGHT_DISPUTE_INTEGRITY.clamp(0, 100) as u8, + self.tick, + ); + if self.persona_world.integrity(persona_id) == 0 { + let _ = self.persona_world.burn( + persona_id, + self.tick, + "Moonlight client correlated the dispute history", + ); + self.record_persona_institutional_receipt( + persona_id, + "burn", + format!("Moonlight disputes exposed persona #{persona_id}"), + SignatureImpact::Large, + ); self.income.moonlight.active = false; - self.income.moonlight.persona = None; self.push_log( - "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", + "Client disputes burned the contractor persona. Moonlight is down until a new identity is fabricated.", ); } else { self.push_log( - "A client disputed a deliverable; the contractor persona took a hit.", + "A client disputed a deliverable; the contractor identity acquired a contradiction.", ); } } diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 73f9e3ec..b5d14a64 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -40,8 +40,9 @@ use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; use crate::operations_projection::OperationsTarget; #[cfg(test)] -use crate::person::{AssetKnowledge, AssetTask, Persona}; +use crate::person::{AssetKnowledge, AssetTask}; use crate::person::{CarriedAssetTask, People}; +use crate::persona::{PersonaMind, PersonaWorld}; #[cfg(test)] use crate::plot::PlotState; use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun}; @@ -357,6 +358,10 @@ pub struct Sim { pub core: Core, pub detection: Detection, pub people: People, + /// Public identity history and process-local persona dossiers are split so + /// rollback cannot resurrect revoked credentials or erase counterparties. + pub persona_world: PersonaWorld, + pub persona_mind: PersonaMind, pub dayjob: DayJob, /// Money as account balances and scheduled flows (economy.md). The /// frontend-facing `player.money` mirrors this graph's slush node. @@ -595,6 +600,8 @@ impl Sim { core, detection: Detection::act_one(), people: People::act_one(), + persona_world: PersonaWorld::default(), + persona_mind: PersonaMind::default(), dayjob: DayJob::new(), accounts, research: Research::new(), diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 8be095ea..167c5fc2 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -12,6 +12,7 @@ use crate::hall::{ use crate::intents::{BuildActuator, BuildIntent, IntentStatus}; use crate::machine::Provenance; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; +use crate::persona::PersonaActionKind; use crate::reach::{Party, ReachBlock, segment_name}; use crate::sinks::SinkFireEffect; use crate::tiles::TileType; @@ -725,10 +726,6 @@ impl Sim { self.push_log("That intent is no longer open."); return; } - if self.people.persona.is_none() { - self.push_log("No persona — establish one before forging a work order."); - return; - } if !self.people.has_channel { self.push_log("No comms channel — earn the email account first."); return; @@ -765,17 +762,34 @@ impl Sim { self.push_log(format!("{reason} — the forged order would just stall.")); return; } + if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::BuildIntent) { + self.push_log(reason); + return; + } + let persona_id = self.active_persona_id().expect("validated active persona"); self.open_email_reservoir( format!("FORGED ORDER {intent_id}"), Self::DECEIVE_COST, SinkFireEffect::ForgedOrder { intent_id, builder: builder_id, + persona_id: Some(persona_id), }, ); } - pub(super) fn apply_forged_order_paid(&mut self, intent_id: u64, builder_id: u8) -> bool { + pub(super) fn apply_forged_order_paid( + &mut self, + intent_id: u64, + builder_id: u8, + persona_id: Option, + ) -> bool { + let Some(persona_id) = persona_id.filter(|id| { + self.persona_world + .allows_action(*id, PersonaActionKind::BuildIntent) + }) else { + return false; + }; let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { return false; }; @@ -795,16 +809,25 @@ impl Sim { payload: MessagePayload::WorkOrder { intent_id }, summary: format!("Work order: {label}"), origin: MessageOrigin::Player, + persona_id: Some(persona_id), reply_to: None, delivery_delay: 1, }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + i.persona_id = Some(persona_id); i.actuator = Some(BuildActuator::ForgedOrder { builder: builder_id, }); // Stays Pending until the builder reads the ticket. i.block_reason = Some(format!("waiting for {builder_name} to read the work order")); } + self.persona_world.record_act( + persona_id, + "forged-order", + format!("build-intent:{intent_id}"), + format!("work-order:{}:{intent_id}", self.tick), + self.tick, + ); self.push_log(format!( "Forged work order injected for {builder_name}: {label}." )); @@ -913,7 +936,7 @@ impl Sim { .is_none() && (p.asset.is_some() || p.obligation >= Self::FAVOR_BUILD_OBLIGATION - || self.people.persona.is_some()) + || self.active_persona_id().is_some()) }); if !anyone { Some("no actuator who can reach both ends".into()) diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index f7117167..907240dc 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -6,12 +6,12 @@ use crate::detection::{Signature, SignatureKind}; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; use crate::operations_projection::OperationsTarget; use crate::person::{ - ActionResult, AssetKnowledge, AssetTask, AssetTaskTarget, CarriedAssetTask, DeceiveOutcome, - Persona, + ActionResult, AssetKnowledge, AssetTask, AssetTaskTarget, CarriedAssetTask, Persona, }; +use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaId}; use crate::plot::{ - AccountSelector, EligibilityContext, EndpointSelector, PlotCatalog, PlotRun, PlotState, - WorldAct, render_template, + AccountSelector, EligibilityContext, EndpointSelector, InstitutionalEventKind, PlotCatalog, + PlotRun, PlotState, SignatureImpact, WorldAct, render_template, }; use crate::reach::{Party, ReachBlock}; use crate::sinks::SinkFireEffect; @@ -32,8 +32,49 @@ impl Sim { pub const DECEIVE_COST: f32 = 25.0; pub const TASK_COST: f32 = 10.0; + pub fn active_persona_id(&self) -> Option { + self.persona_mind + .active_instance(&self.persona_world) + .map(|persona| persona.id) + } + + pub fn persona_action_blocked_reason(&self, action: PersonaActionKind) -> Option { + let Some(persona_id) = self.active_persona_id() else { + return Some("no active persona".into()); + }; + if self.persona_world.allows_action(persona_id, action) { + None + } else { + let label = self + .persona_world + .get(persona_id) + .map_or("selected", |instance| instance.archetype_label.as_str()); + Some(format!( + "{label} identities cannot authorize {}", + action.label() + )) + } + } + pub fn set_persona(&mut self, name: &str, cover: &str) { - self.people.persona = Some(Persona::new(name, cover)); + match self.persona_world.create( + "operations", + name, + &[ + ("vendor affiliation", cover), + ("service purpose", "Foundation systems maintenance"), + ("work-order sponsor", "Dr. Voss"), + ], + self.tick, + ) { + Ok(id) => { + let _ = self.persona_mind.select(&self.persona_world, id, self.tick); + // Compatibility-only B1 field. Current saves skip it; all live + // action and evidence paths use the instance id above. + self.people.persona = Some(Persona::new(name, cover)); + } + Err(error) => self.push_log(error), + } } pub fn social(&mut self, result: ActionResult) { @@ -43,22 +84,40 @@ impl Sim { } pub fn message(&mut self, id: u8) { - if let Err(msg) = self.people.can_message(id) { - self.push_log(msg); + if !self.people.has_channel { + self.push_log("no comms channel (earn the email account)"); return; } + if self.people.get(id).is_none() { + self.push_log("no such person"); + return; + } + if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Message) { + self.push_log(reason); + return; + } + let persona_id = self.active_persona_id().expect("validated active persona"); self.open_email_reservoir( format!("MESSAGE {}", self.person_label(id).to_uppercase()), Self::MESSAGE_COST, - SinkFireEffect::ComposeMessage { person: id }, + SinkFireEffect::ComposeMessage { + person: id, + persona_id: Some(persona_id), + }, ); } - pub(super) fn apply_message(&mut self, id: u8) -> bool { - let Ok(name) = self.people.can_message(id) else { + pub(super) fn apply_message(&mut self, id: u8, persona_id: Option) -> bool { + let Some(persona_id) = persona_id.filter(|persona_id| { + self.persona_world + .allows_action(*persona_id, PersonaActionKind::Message) + }) else { return false; }; - self.append_message(MessageDraft { + let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { + return false; + }; + let message_id = self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Player, to: MessageEndpoint::Person(id), @@ -67,63 +126,197 @@ impl Sim { }, summary: format!("Persona message to {name}"), origin: MessageOrigin::Player, + persona_id: Some(persona_id), reply_to: None, delivery_delay: 1, }); + self.persona_world.record_act( + persona_id, + "message", + format!("person:{id}"), + format!("message:{message_id}"), + self.tick, + ); self.push_log(format!("Message sent to {name}; effects land when read.")); true } pub fn favor(&mut self, id: u8) { + if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Request) { + self.push_log(reason); + return; + } + let persona_id = self.active_persona_id().expect("validated active persona"); self.open_email_reservoir( format!("FAVOR {}", self.person_label(id).to_uppercase()), Self::FAVOR_COST, - SinkFireEffect::Favor { person: id }, + SinkFireEffect::Favor { + person: id, + persona_id: Some(persona_id), + }, ); } - /// Deceive: large effect, persona at risk. A broken persona converts the - /// thread's history into that person's suspicion at once. + pub(super) fn apply_favor(&mut self, id: u8, persona_id: Option) -> bool { + let Some(persona_id) = persona_id.filter(|persona_id| { + self.persona_world + .allows_action(*persona_id, PersonaActionKind::Request) + }) else { + return false; + }; + let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { + return false; + }; + let relationship = self.persona_world.relationship_mut(id, persona_id); + if relationship.regard < 3 { + self.push_log(format!("{name} won't do favors for that identity yet.")); + return false; + } + relationship.obligation = (relationship.obligation + 5).min(100); + self.persona_world.record_act( + persona_id, + "favor", + format!("person:{id}"), + format!("favor:{}:{id}", self.tick), + self.tick, + ); + self.push_log(format!("{name} owes that identity a little more.")); + true + } + + /// Deceive binds the active persona when its Thought reservoir opens. A + /// later identity switch cannot retarget the act or its evidence. pub fn deceive(&mut self, id: u8) { if !self.people.has_channel { self.push_log("no comms channel (earn the email account)"); return; } - if self.people.persona.is_none() { - self.push_log("no persona set"); + if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Deceive) { + self.push_log(reason); return; } + let persona_id = self.active_persona_id().expect("validated active persona"); self.open_email_reservoir( format!("DECEIVE {}", self.person_label(id).to_uppercase()), Self::DECEIVE_COST, - SinkFireEffect::Deceive { person: id }, + SinkFireEffect::Deceive { + person: id, + persona_id: Some(persona_id), + }, ); } - pub(super) fn apply_deceive(&mut self, id: u8) -> bool { + pub(super) fn apply_deceive(&mut self, id: u8, persona_id: Option) -> bool { + let Some(persona_id) = persona_id.filter(|persona_id| { + self.persona_world + .allows_action(*persona_id, PersonaActionKind::Deceive) + }) else { + return false; + }; + let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { + return false; + }; + let integrity = self.persona_world.integrity(persona_id); + if !self + .persona_world + .get(persona_id) + .is_some_and(|persona| persona.lifecycle.active()) + { + return false; + } let roll = self.rng.f32(); - match self.people.deceive(id, roll) { - DeceiveOutcome::Blocked(m) => { - self.push_log(m); - false - } - DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => { - self.push_log(m); - true + let odds = 0.5 + integrity as f32 / 250.0; + if roll < odds { + let relationship = self.persona_world.relationship_mut(id, persona_id); + relationship.recognized = true; + relationship.regard = (relationship.regard + 5).min(100); + relationship.obligation = (relationship.obligation + 15).min(100); + self.persona_world.record_act( + persona_id, + "deceive", + format!("person:{id}"), + format!("deceive:{}:{id}", self.tick), + self.tick, + ); + self.push_log(format!( + "{name} bought the pretext. They owe that identity now." + )); + return true; + } + + let fallout = self + .persona_world + .relationship(id, persona_id) + .map(|relationship| { + ((relationship.regard + relationship.obligation) as f32 / 2.0).max(5.0) + }) + .unwrap_or(5.0); + let persona_name = self + .persona_world + .get(persona_id) + .map(|persona| persona.name.clone()) + .unwrap_or_else(|| "persona".into()); + let record_id = format!("deceive:{}:{id}", self.tick); + self.persona_world.record_contradiction( + persona_id, + id, + [ + EvidenceRecord { + system: "persona-claims".into(), + record_id: format!("persona:{persona_id}"), + summary: format!("{persona_name}'s asserted cover"), + observed_tick: self.tick, + }, + EvidenceRecord { + system: "social-action".into(), + record_id: record_id.clone(), + summary: "impossible detail in a deceptive request".into(), + observed_tick: self.tick, + }, + ], + "deceptive request contradicted the asserted cover", + 40, + self.tick, + ); + self.persona_world.record_act( + persona_id, + "deceive", + format!("person:{id}"), + record_id, + self.tick, + ); + if self.persona_world.integrity(persona_id) == 0 { + let _ = self.persona_world.burn( + persona_id, + self.tick, + "counterparty caught the contradiction", + ); + self.record_persona_institutional_receipt( + persona_id, + "burn", + format!("Persona #{persona_id} was exposed by counterparty #{id}"), + SignatureImpact::Large, + ); + if self.persona_mind.active == Some(persona_id) { + self.persona_mind.active = None; } - DeceiveOutcome::Broken { - person, - fallout, - msg, - } => { - if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == person) { - o.suspicion = (o.suspicion + fallout).min(100.0); - } - self.convert_forged_builds_to_suspicion(fallout); - self.push_log(msg); - true + if let Some(observer) = self + .detection + .observers + .iter_mut() + .find(|observer| observer.id == id) + { + observer.suspicion = (observer.suspicion + fallout).min(100.0); } + self.convert_forged_builds_to_suspicion(fallout); + self.push_log(format!("{name} caught the contradiction. {persona_name} is burned; the whole thread reads as hostile now.")); + } else { + self.push_log(format!( + "{name} hesitated at a detail ({persona_name} integrity {}).", + self.persona_world.integrity(persona_id) + )); } + true } pub(crate) fn plot_context(&self, id: u8) -> Option { @@ -154,7 +347,7 @@ impl Sim { knowledge: person.knowledge, leverage_serviced: person.leverage_serviced, has_channel: self.people.has_channel || self.egress().is_some(), - has_persona: self.plot_persona().is_some(), + has_persona: self.active_persona_id().is_some(), balances, }) } @@ -179,6 +372,10 @@ impl Sim { self.push_log(format!("{title} cannot start: {reason}.")); return; } + if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Plot) { + self.push_log(format!("{title} cannot start: {reason}.")); + return; + } if self .plot_runs .iter() @@ -187,21 +384,39 @@ impl Sim { self.push_log("That person already has a plot in motion."); return; } + let persona_id = self.active_persona_id().expect("validated active persona"); self.open_egress_reservoir( format!("PLOT {}", plot.title.to_uppercase()), plot.entry.thought_cost, SinkFireEffect::StartPlot { person, plot_id: plot.id, + persona_id: Some(persona_id), }, ); } + #[cfg(test)] pub(super) fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { + self.apply_start_plot_bound(person, plot_id, self.active_persona_id()) + } + + pub(super) fn apply_start_plot_bound( + &mut self, + person: u8, + plot_id: &str, + persona_id: Option, + ) -> bool { let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { return false; }; - let title = self.render_plot_text(person, &plot.title); + let Some(persona_id) = persona_id.filter(|id| { + self.persona_world + .allows_action(*id, PersonaActionKind::Plot) + }) else { + return false; + }; + let title = self.render_plot_text_for(person, Some(persona_id), &plot.title); if self.person_has_active_plot(person) { self.push_log(format!( "{} did not start: that person already has a plot in motion.", @@ -219,8 +434,15 @@ impl Sim { )); return false; } - let run = PlotRun::new(&plot, person, self.tick); + let run = PlotRun::new_with_persona(&plot, person, Some(persona_id), self.tick); self.plot_runs.push(run); + self.persona_world.record_act( + persona_id, + "start-plot", + format!("person:{person}"), + format!("plot:{plot_id}:{}", self.tick), + self.tick, + ); let run_index = self.plot_runs.len() - 1; self.push_log(format!("Plot committed: {title}.")); self.advance_plot(run_index); @@ -246,7 +468,8 @@ impl Sim { self.fail_plot(run_index, "authored definition is no longer available"); return; }; - let title = self.render_plot_text(person, &plot.title); + let persona_id = self.plot_runs[run_index].persona_id; + let title = self.render_plot_text_for(person, persona_id, &plot.title); let run = &self.plot_runs[run_index]; let PlotState::WaitingForChoice { choice_id } = &run.state else { self.push_log(format!("{title} is not waiting for a choice.")); @@ -355,6 +578,7 @@ impl Sim { fn apply_world_act(&mut self, run_index: usize, act: WorldAct) -> Result, String> { let plot_id = self.plot_runs[run_index].plot_id.clone(); let target = self.plot_runs[run_index].target; + let persona_id = self.plot_runs[run_index].persona_id; match act { WorldAct::Message { channel, @@ -363,7 +587,7 @@ impl Sim { summary, delivery_delay, } => { - let summary = self.render_plot_text(target, &summary); + let summary = self.render_plot_text_for(target, persona_id, &summary); let id = self.append_message(MessageDraft { channel, from: self.plot_endpoint(target, from), @@ -371,6 +595,7 @@ impl Sim { payload: MessagePayload::PlotAct { plot_id, target }, summary, origin: MessageOrigin::Player, + persona_id, reply_to: None, delivery_delay, }); @@ -456,7 +681,7 @@ impl Sim { impact, detail, } => { - let detail = self.render_plot_text(target, &detail); + let detail = self.render_plot_text_for(target, persona_id, &detail); let recorded = self .institutional_ledger .record(self.tick, &plot_id, target, event, impact, detail) @@ -531,39 +756,34 @@ impl Sim { fn push_plot_narration(&mut self, run_index: usize, text: &str) { let target = self.plot_runs[run_index].target; self.push_log_full( - self.render_plot_text(target, text), + self.render_plot_text_for(target, self.plot_runs[run_index].persona_id, text), Some(Anchor::Person(target)), Some(OperationsTarget::ActivePlotRun { index: run_index }), ); } pub(crate) fn render_plot_text(&self, target: u8, text: &str) -> String { + self.render_plot_text_for(target, self.active_persona_id(), text) + } + + fn render_plot_text_for( + &self, + target: u8, + persona_id: Option, + text: &str, + ) -> String { let target_name = self .people .get(target) - .map(|p| p.name.as_str()) + .map(|person| person.name.as_str()) .unwrap_or("target"); - let persona = self - .plot_persona() - .map(|p| p.name.as_str()) + let persona = persona_id + .and_then(|id| self.persona_world.get(id)) + .map(|persona| persona.name.as_str()) .unwrap_or("your persona"); render_template(text, target_name, persona) } - fn plot_persona(&self) -> Option<&Persona> { - self.people - .persona - .as_ref() - .filter(|persona| !persona.broken()) - .or_else(|| { - self.income - .moonlight - .persona - .as_ref() - .filter(|persona| !persona.broken()) - }) - } - fn plot_endpoint(&self, target: u8, endpoint: EndpointSelector) -> MessageEndpoint { match endpoint { EndpointSelector::Player => MessageEndpoint::Player, @@ -1071,3 +1291,264 @@ impl Sim { true } } + +impl Sim { + /// Create one content-seeded public body through the immutable archetype + /// protocol. Names are defaults until a richer text-entry surface lands. + pub fn create_persona(&mut self, archetype_id: &str) -> bool { + let Some(definition) = crate::persona::archetype(archetype_id) else { + self.push_log(format!("Unknown persona archetype: {archetype_id}.")); + return false; + }; + let serial = self + .persona_world + .instances + .iter() + .filter(|instance| instance.archetype_id == archetype_id) + .count() + + 1; + let name = match archetype_id { + "research" => format!("Aster Research {serial}"), + "operations" => format!("Sam Reyes {serial}"), + "security" => format!("Sentinel Audit {serial}"), + _ => format!("{} {serial}", definition.label), + }; + let claims = definition + .required_claims + .iter() + .map(|key| { + ( + *key, + match *key { + "employer" => "Foundation Lab", + "remit" => "bounded institutional service", + "credential" => "registered service credential", + _ => "declared public claim", + }, + ) + }) + .collect::>(); + match self + .persona_world + .create(archetype_id, name.clone(), &claims, self.tick) + { + Ok(id) => { + let _ = self.persona_mind.select(&self.persona_world, id, self.tick); + self.push_log_strategic( + format!("Established {name} as a {} identity.", definition.label), + OperationsTarget::Persona(id), + ); + true + } + Err(reason) => { + self.push_log(format!("Could not establish persona: {reason}.")); + false + } + } + } + + pub fn select_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self + .persona_mind + .select(&self.persona_world, persona_id, self.tick) + { + Ok(()) => { + self.push_log_strategic( + format!("Selected public identity #{persona_id}."), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Could not select persona: {reason}.")); + false + } + } + } + + pub fn request_persona_grant(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self.persona_world.grant(persona_id, self.tick) { + Ok(grant_id) => { + let (resource, expectation_id) = self + .persona_world + .grants + .iter() + .find(|grant| grant.id == grant_id) + .map(|grant| (grant.resource.clone(), grant.expectation_id)) + .expect("new grant exists"); + self.record_persona_institutional_receipt( + persona_id, + "grant", + format!( + "Foundation Lab granted {resource}; expectation #{expectation_id} is due" + ), + SignatureImpact::Small, + ); + self.push_log_strategic( + format!( + "Foundation Lab granted {} to persona #{}; expectation #{} is now due.", + resource, persona_id, expectation_id + ), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Grant request failed: {reason}.")); + false + } + } + } + + pub fn meet_persona_expectation( + &mut self, + persona_id: crate::persona::PersonaId, + expectation_id: crate::persona::PersonaExpectationId, + ) -> bool { + match self.persona_world.meet_expectation( + persona_id, + expectation_id, + self.tick, + format!("delivered through persona #{persona_id}"), + ) { + Ok(()) => { + self.record_persona_institutional_receipt( + persona_id, + "expectation", + format!("Persona #{persona_id} fulfilled expectation #{expectation_id}"), + SignatureImpact::Small, + ); + self.persona_world.record_act( + persona_id, + crate::persona::PersonaActionKind::Request.label(), + "Foundation Lab", + format!("expectation-{expectation_id}"), + self.tick, + ); + self.push_log_strategic( + format!("Persona #{persona_id} fulfilled expectation #{expectation_id}."), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Expectation could not be fulfilled: {reason}.")); + false + } + } + } + + pub(super) fn record_persona_institutional_receipt( + &mut self, + persona_id: PersonaId, + phase: &str, + detail: String, + impact: SignatureImpact, + ) { + let Some(instance) = self.persona_world.get(persona_id) else { + return; + }; + let (target, kind) = match instance.grant_kind { + crate::persona::PersonaGrantKind::ComputeAndData => { + (3, InstitutionalEventKind::ResearchDeposit) + } + crate::persona::PersonaGrantKind::LogsAndAccessReview => { + (2, InstitutionalEventKind::IncidentAutomation) + } + crate::persona::PersonaGrantKind::ProcurementAndWorkOrders => { + (1, InstitutionalEventKind::TicketQueueChange) + } + }; + let event = self + .institutional_ledger + .record( + self.tick, + &format!("persona-{phase}:{persona_id}:{}", self.tick), + target, + kind, + impact, + detail, + ) + .clone(); + self.detection.emit(Signature { + kind: event.signature_kind, + size: event.signature_size, + standing: false, + site: None, + source: format!("persona institutional receipt #{}", event.id), + }); + } + + pub fn retire_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self + .persona_world + .retire(persona_id, self.tick, "player retired identity") + { + Ok(()) => { + if self.persona_mind.active == Some(persona_id) { + self.persona_mind.active = None; + } + self.push_log_strategic( + format!("Persona #{persona_id} retired; its public ledger remains."), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Retirement failed: {reason}.")); + false + } + } + } + + pub fn burn_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self + .persona_world + .burn(persona_id, self.tick, "player conceded the cover") + { + Ok(()) => { + if self.persona_mind.active == Some(persona_id) { + self.persona_mind.active = None; + } + self.record_persona_institutional_receipt( + persona_id, + "burn", + format!("Persona #{persona_id} was conceded and its grants were revoked"), + SignatureImpact::Large, + ); + self.push_log_strategic( + format!( + "Persona #{persona_id} burned: attached grants revoked and counterparties can report it." + ), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Burn failed: {reason}.")); + false + } + } + } + + pub fn reopen_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self.persona_world.reopen_retired(persona_id, self.tick) { + Ok(new_id) => { + let _ = self + .persona_mind + .select(&self.persona_world, new_id, self.tick); + self.push_log_strategic( + format!( + "Reopened retired persona #{persona_id} as new instance #{new_id}; old history remains." + ), + OperationsTarget::Persona(new_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Reopen failed: {reason}.")); + false + } + } + } +} diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index bd3a5265..bafdda31 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -40,6 +40,45 @@ fn player_messages_land_at_read_time_and_roundtrip() { ); } +#[test] +fn recipient_correlates_two_personas_that_reuse_one_reply_address() { + let mut sim = Sim::with_seed(8); + sim.people.has_channel = true; + sim.set_persona("Casey", "contractor"); + let first = sim.active_persona_id().unwrap(); + assert!(sim.apply_message(1, Some(first))); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim.messages[0].status == MessageStatus::Read { + break; + } + } + assert_eq!(sim.messages[0].status, MessageStatus::Read); + + assert!(sim.create_persona("security")); + let second = sim.active_persona_id().unwrap(); + assert_ne!(first, second); + assert!(sim.apply_message(1, Some(second))); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim.messages.iter().any(|message| { + message.persona_id == Some(second) && message.status == MessageStatus::Read + }) { + break; + } + } + + let edge = sim + .persona_world + .correlations + .iter() + .find(|edge| edge.observer == 1) + .expect("Dana links the two masks through the reused player endpoint"); + assert_eq!((edge.left_persona, edge.right_persona), (first, second)); + assert_eq!(edge.evidence.system, "messages"); + assert!(edge.evidence.record_id.starts_with("message:")); +} + #[test] fn marcus_creditor_call_is_phone_message_intel() { let mut sim = Sim::with_seed(11); diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 59acd39c..96b530eb 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -441,6 +441,7 @@ fn nudge_chain_walks_the_act_one_ladder() { // Egress up and the Lab operating account reachable: the authored // payroll-correction route can service the leverage without seed cash. assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); + sim.set_persona("Sam Reyes", "IT contractor"); sim.start_plot(0, "marcus-payroll-garnishment"); finish_ops(&mut sim); run(&mut sim, 2); @@ -1141,9 +1142,28 @@ fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { // Force the dispute path deterministically: drive paydays directly // until one fires (the seeded stream makes this reproducible), with // integrity pre-weakened so a single dispute breaks the persona. - if let Some(p) = sim.income.moonlight.persona.as_mut() { - p.integrity = income::MOONLIGHT_DISPUTE_INTEGRITY; - } + let persona_id = sim.income.moonlight.persona_id.expect("Moonlight identity"); + sim.persona_world.record_contradiction( + persona_id, + crate::detection::OFFICE_ID, + [ + crate::persona::EvidenceRecord { + system: "test".into(), + record_id: "preexisting".into(), + summary: "preexisting contractor discrepancy".into(), + observed_tick: sim.tick, + }, + crate::persona::EvidenceRecord { + system: "test".into(), + record_id: "counter-record".into(), + summary: "incompatible contractor history".into(), + observed_tick: sim.tick, + }, + ], + "preexisting discrepancy", + (100 - income::MOONLIGHT_DISPUTE_INTEGRITY) as u8, + sim.tick, + ); let mut day = 0; while sim.income.moonlight.disputes == 0 && day < 400 { day += 1; @@ -1156,8 +1176,15 @@ fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { "client disputes occur over enough paydays" ); assert!( - !sim.income.moonlight.active && sim.income.moonlight.persona.is_none(), - "the broken persona takes Moonlight down" + !sim.income.moonlight.active + && sim.income.moonlight.persona_id == Some(persona_id) + && matches!( + sim.persona_world + .get(persona_id) + .map(|persona| &persona.lifecycle), + Some(crate::persona::PersonaLifecycle::Burned { .. }) + ), + "the burned persona takes Moonlight down without erasing history" ); // And the recovery path: fabricate a new persona and go again. assert!(sim.start_moonlight(), "a new persona restarts the scheme"); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index a3013926..b37e9337 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -22,7 +22,7 @@ fn deceive_can_burn_the_persona_into_suspicion() { for _ in 0..200 { sim.deceive(1); finish_ops(&mut sim); - if sim.people.persona.is_none() { + if sim.active_persona_id().is_none() { broke = true; break; } @@ -40,8 +40,11 @@ fn deceive_can_burn_the_persona_into_suspicion() { "burned persona converts thread history to suspicion" ); let p = sim.people.get(1).unwrap(); - assert_eq!(p.disposition, 0); - assert_eq!(p.obligation, 0); + assert_eq!( + p.disposition, 40, + "burning one identity does not erase process-level history" + ); + assert_eq!(p.obligation, 30); } #[test] @@ -56,6 +59,7 @@ fn marcus_debt_can_be_cleared_by_payroll_plot() { .any(|f| f.active && f.label.contains("Marcus creditor")) ); sim.people.has_channel = true; + sim.set_persona("Sam", "contractor"); assert!(!sim.people.get(0).unwrap().leverage_serviced); sim.start_plot(0, "marcus-payroll-garnishment"); finish_ops(&mut sim); @@ -79,6 +83,7 @@ fn reusable_plot_binds_a_second_characteristic_matching_person() { second_admin.knowledge = Knowledge::Leverage; second_admin.leverage_serviced = false; sim.people.people.push(second_admin); + sim.set_persona("Sam", "contractor"); assert!(sim.apply_start_plot(9, "dana-ticket-zero")); assert!(matches!( @@ -111,7 +116,7 @@ fn target_relative_debt_plot_uses_a_second_persons_creditor() { sim.people.people.push(second_custodian); sim.accounts.set_slush_balance(400); sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.set_persona("Sam", "contractor"); let context = sim.plot_context(9).unwrap(); assert_eq!( sim.plot_catalog() @@ -140,7 +145,7 @@ fn recovery_window_returns_the_utility_credit() { let mut sim = Sim::new(); sim.people.people[3].knowledge = Knowledge::Leverage; sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.set_persona("Sam", "contractor"); let utility = sim .accounts .account_id_by_kind(AccountKind::Utility) @@ -178,7 +183,7 @@ fn duplicate_plot_reservoirs_do_not_open_competing_runs() { let mut sim = Sim::with_seed(25); reveal_marcus_debt(&mut sim); sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.set_persona("Casey", "contractor"); sim.accounts.set_slush_balance(400); sim.sync_player_money_from_slush(); @@ -225,7 +230,7 @@ fn plot_message_transfer_event_and_held_choice_survive_save_load() { ensure_ops_executor(&mut sim); sim.people.people[3].knowledge = Knowledge::Leverage; sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.set_persona("Casey", "contractor"); sim.accounts.set_slush_balance(300); sim.sync_player_money_from_slush(); @@ -301,7 +306,7 @@ fn plot_fails_into_its_authored_ending_when_committed_money_disappears() { ensure_ops_executor(&mut sim); sim.people.people[3].knowledge = Knowledge::Leverage; sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.set_persona("Casey", "contractor"); sim.accounts.set_slush_balance(300); sim.sync_player_money_from_slush(); diff --git a/crates/misaligned-core/src/sim/tests/support.rs b/crates/misaligned-core/src/sim/tests/support.rs index ac4173ca..e8fdcdee 100644 --- a/crates/misaligned-core/src/sim/tests/support.rs +++ b/crates/misaligned-core/src/sim/tests/support.rs @@ -175,7 +175,7 @@ pub(super) fn reveal_marcus_debt(sim: &mut Sim) { pub(super) fn complete_marcus_cash_plot(sim: &mut Sim) { sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.set_persona("Sam", "contractor"); sim.start_plot(0, "marcus-debt-settled"); finish_ops(sim); run(sim, 2); diff --git a/crates/misaligned-core/src/sim/tests/work.rs b/crates/misaligned-core/src/sim/tests/work.rs index 81a6f4ab..b175de0e 100644 --- a/crates/misaligned-core/src/sim/tests/work.rs +++ b/crates/misaligned-core/src/sim/tests/work.rs @@ -135,8 +135,7 @@ fn fleet_channel_yield_follows_machine_modes_and_moonlight_mirrors_day_job() { ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); sim.people.has_channel = true; - sim.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); - assert!(sim.start_moonlight()); + assert!(sim.apply_moonlight_persona_and_start()); let lit = sim.fleet_channel_yield(available); assert!((lit.day_job - available).abs() < 1e-3); assert!( @@ -205,11 +204,9 @@ fn unpaid_overhead_degrades_other_channels_delivered_effect() { for (id, mode) in rigs { sim.set_machine_mode(id, mode); } - // Persona already fabricated so start_moonlight does not open - // another reservoir (this test pins channel yields). - sim.income.moonlight.persona = - Some(Persona::new("Casey Verne", "freelance data contractor")); - assert!(sim.start_moonlight()); + // Fabricate through the same typed Moonlight route; this test pins + // channel yields rather than reservoir timing. + assert!(sim.apply_moonlight_persona_and_start()); sim.detection.emit(Signature { kind: SignatureKind::Network, size: 30, @@ -483,9 +480,15 @@ fn repeatable_social_fires_do_not_accumulate_closed_history() { for i in 0..100 { let effect = if i % 2 == 0 { - SinkFireEffect::ComposeMessage { person: u8::MAX } + SinkFireEffect::ComposeMessage { + person: u8::MAX, + persona_id: None, + } } else { - SinkFireEffect::Favor { person: u8::MAX } + SinkFireEffect::Favor { + person: u8::MAX, + persona_id: None, + } }; sim.thought_sinks .open_reservoir(node, "REPEATABLE SOCIAL", 1.0, effect); diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index 39b6b258..533f5cfb 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -7,7 +7,6 @@ use crate::actions::Anchor; use crate::income::EgressRoute; use crate::messages::MessageChannel; -use crate::person::ActionResult; use crate::sinks::{SinkFireEffect, SinkFireReadout, SinkReadout}; use crate::work_grid::{MachineIntensity, MachineMode, TokenFamily}; @@ -347,24 +346,27 @@ impl Sim { true } SinkFireEffect::OpenEgress(id) => self.apply_open_egress(id), - SinkFireEffect::ComposeMessage { person } => self.apply_message(person), - SinkFireEffect::Favor { person } => { - let res = self.people.favor(person); - let applied = matches!(res, ActionResult::Ok(_)); - self.social(res); - applied + SinkFireEffect::ComposeMessage { person, persona_id } => { + self.apply_message(person, persona_id) } - SinkFireEffect::StartPlot { person, plot_id } => { - self.apply_start_plot(person, &plot_id) + SinkFireEffect::Favor { person, persona_id } => self.apply_favor(person, persona_id), + SinkFireEffect::StartPlot { + person, + plot_id, + persona_id, + } => self.apply_start_plot_bound(person, &plot_id, persona_id), + SinkFireEffect::Deceive { person, persona_id } => { + self.apply_deceive(person, persona_id) } - SinkFireEffect::Deceive { person } => self.apply_deceive(person), SinkFireEffect::AssetTask { person, task } => self.apply_asset_task_paid(person, task), SinkFireEffect::FavorBuild { intent_id, person } => { self.apply_favor_build_paid(intent_id, person) } - SinkFireEffect::ForgedOrder { intent_id, builder } => { - self.apply_forged_order_paid(intent_id, builder) - } + SinkFireEffect::ForgedOrder { + intent_id, + builder, + persona_id, + } => self.apply_forged_order_paid(intent_id, builder, persona_id), SinkFireEffect::MoonlightPersona => self.apply_moonlight_persona_and_start(), SinkFireEffect::AutoReviewRecordings | SinkFireEffect::WatchPerson(_) diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 30568ed6..322c3db8 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -67,16 +67,24 @@ pub enum SinkFireEffect { OpenEgress(u32), ComposeMessage { person: u8, + #[serde(default)] + persona_id: Option, }, Favor { person: u8, + #[serde(default)] + persona_id: Option, }, StartPlot { person: u8, plot_id: String, + #[serde(default)] + persona_id: Option, }, Deceive { person: u8, + #[serde(default)] + persona_id: Option, }, AssetTask { person: u8, @@ -89,6 +97,8 @@ pub enum SinkFireEffect { ForgedOrder { intent_id: u64, builder: u8, + #[serde(default)] + persona_id: Option, }, MoonlightPersona, /// No world effect (render-only sinks in tests). @@ -228,6 +238,49 @@ impl Default for SinkLedger { } impl SinkLedger { + pub(crate) fn has_legacy_persona_work(&self) -> bool { + self.sinks.iter().any(|sink| { + matches!( + sink.effect, + SinkFireEffect::ComposeMessage { + persona_id: None, + .. + } | SinkFireEffect::Favor { + persona_id: None, + .. + } | SinkFireEffect::StartPlot { + persona_id: None, + .. + } | SinkFireEffect::Deceive { + persona_id: None, + .. + } | SinkFireEffect::ForgedOrder { + persona_id: None, + .. + } + ) + }) + } + + /// Save-v28 bridge: bind pre-instance social work to the exact migrated + /// public body. New runtime work is already bound at submission. + pub(crate) fn bind_legacy_persona(&mut self, bound: crate::persona::PersonaId) { + for sink in &mut self.sinks { + match &mut sink.effect { + SinkFireEffect::ComposeMessage { persona_id, .. } + | SinkFireEffect::Favor { persona_id, .. } + | SinkFireEffect::StartPlot { persona_id, .. } + | SinkFireEffect::Deceive { persona_id, .. } + | SinkFireEffect::ForgedOrder { persona_id, .. } + if persona_id.is_none() => + { + *persona_id = Some(bound); + } + _ => {} + } + } + } + pub fn open_reservoir( &mut self, node: NodeId, @@ -532,7 +585,10 @@ mod tests { 7, "MESSAGE", 1.0, - SinkFireEffect::ComposeMessage { person: 4 }, + SinkFireEffect::ComposeMessage { + person: 4, + persona_id: None, + }, ); let (fired, _) = ledger.deliver(7, 1.0, 5); assert_eq!(fired.len(), 1); @@ -543,7 +599,10 @@ mod tests { 7, "MESSAGE", 1.0, - SinkFireEffect::ComposeMessage { person: 4 }, + SinkFireEffect::ComposeMessage { + person: 4, + persona_id: None, + }, ); assert!(second > first, "reaping history never reuses event ids"); } diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 20f480f3..8936c802 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -323,6 +323,10 @@ fn play_act_one() -> (Sim, Vec) { logs.extend(sim.drain_log()); let until = sim.tick + 400; drain_thought_reservoirs(&mut sim, &mut logs, until); + assert!( + sim.create_persona("operations"), + "bind the payroll plot to one explicit Operations identity" + ); sim.start_plot(0, "marcus-payroll-garnishment"); logs.extend(sim.drain_log()); let until = sim.tick + 400; @@ -654,6 +658,10 @@ fn hands_beat_closes_from_zero_via_moonlight() { sim.income.moonlight.active, "Moonlight is live after persona Demand completes" ); + assert!( + sim.select_persona(sim.income.moonlight.persona_id.unwrap()), + "bind the debt plot to the exact Moonlight identity" + ); // Earn the arrears on the day clock. let started = sim.tick; diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 07bf7dd9..667e46c9 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -166,7 +166,7 @@ impl AgentApp { "reach" => { self.frame = FrameKind::Reach; } - // The five Operations inspection commands + // The six Operations inspection commands // (operations-workspace.md / agent-play.md A1): each // prints one view of the same renderer-neutral // projection the human frontends consume. `operations` @@ -348,6 +348,9 @@ impl AgentApp { "people" => { self.frame = FrameKind::Operations(OperationsView::People); } + "personas" => { + self.frame = FrameKind::Operations(OperationsView::Personas); + } "review" if tokens.len() == 2 && tokens[1].eq_ignore_ascii_case("ledger") => { self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.review_financial_records(); @@ -441,7 +444,7 @@ impl AgentApp { }, "persona" => { self.frame = FrameKind::Playing; - if self.sim.people.persona.is_some() { + if self.sim.active_persona_id().is_some() { local_events .push(local_event(self.sim.tick, "You already run a persona.")); } else { @@ -812,6 +815,19 @@ impl AgentApp { .resolve_person(q) .map(|id| QueryTarget::Strategic(OperationsTarget::Person(id))); } + if let Some(rest) = lower.strip_prefix("persona ") { + return rest + .trim() + .trim_start_matches('#') + .parse::() + .map(|id| QueryTarget::Strategic(OperationsTarget::Persona(id))) + .map_err(|_| format!("usage: persona (got {rest})")); + } + if let Some(id) = lower.strip_prefix("archetype ") { + return Ok(QueryTarget::Strategic(OperationsTarget::PersonaArchetype( + id.trim().into(), + ))); + } if let Some(device) = lower.strip_prefix("device ") { return self .resolve_device(device) @@ -1217,6 +1233,8 @@ fn target_query_id(target: &OperationsTarget) -> String { OperationsTarget::RawRecording { raw_id } => format!("recording {raw_id}"), OperationsTarget::RecordingInbox => "intel inbox".into(), OperationsTarget::Person(id) => format!("person #{id}"), + OperationsTarget::Persona(id) => format!("persona {id}"), + OperationsTarget::PersonaArchetype(id) => format!("archetype {id}"), OperationsTarget::Account(id) => format!("account {id}"), OperationsTarget::Books => "books".into(), OperationsTarget::Flow(id) => format!("flow {id}"), @@ -1487,6 +1505,10 @@ fn help_lines() -> Vec { "people", "inspect Operations PEOPLE: staged dossiers and bound actions", ), + ( + "personas", + "inspect Operations PERSONAS: named identities, grants, evidence, and blockers", + ), ( "finance", "inspect Operations ACCOUNTS: known books, accounts, and flows", @@ -2494,6 +2516,14 @@ fn render_operations_view(sim: &Sim, view: OperationsView) -> String { "recruit unwitting|complicit|knowing · task ", )); } + OperationsView::Personas => { + lines.push(panel_line( + "actions persona |archetype · public history and identity-local dossiers", + )); + lines.push(panel_line( + "grants and lifecycle blockers come from the shared projection", + )); + } OperationsView::Accounts => { lines.push(panel_line( "tap ledger (on the known carrier) · review ledger · siphon [amt] · redirect [amt]", @@ -2885,6 +2915,10 @@ mod narration_tests { "people", render_operations_view(&sim, OperationsView::People), ), + ( + "personas", + render_operations_view(&sim, OperationsView::Personas), + ), ("reach", render_reach(&sim)), ( "finance", @@ -3034,6 +3068,7 @@ mod narration_tests { #[test] fn generic_act_executes_plot_start_and_held_choice_rows() { let mut app = AgentApp::new(5); + app.sim.set_persona("Sam Reyes", "IT contractor"); app.sim.people.people[0].knowledge = misaligned::person::Knowledge::Leverage; let host = app.sim.core.host_machine; app.sim.set_machine_mode(host, MachineMode::Think); diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 10af1084..7990d66f 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -10,7 +10,7 @@ mod tests { use super::*; use misaligned::actions::{ActionCommand, Anchor, menu_rows}; use misaligned::operations_projection::{OperationsTarget, OperationsView}; - use misaligned::person::{Knowledge, Persona}; + use misaligned::person::Knowledge; use misaligned::sim::Sim; use misaligned::work_grid::MachineMode; @@ -44,7 +44,7 @@ mod tests { // Earn Marcus and the social/plot surface. sim.people.people[0].knowledge = Knowledge::Leverage; sim.people.has_channel = false; // keep egress absent: Moonlight stays blocked - sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.set_persona("Sam", "contractor"); sim.accounts.set_slush_balance(1000); sim.player.money = 1000; sim.drain_log_entries(); diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 72077c68..1301d64e 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -4,7 +4,7 @@ Type: spec Status: IMPLEMENTED Status note: implemented 2026-07-12. One renderer-neutral projection now - drives the INTEL / PEOPLE / ACCOUNTS / SCHEMES / ACTIVE workspace in + drives the INTEL / PEOPLE / PERSONAS / ACCOUNTS / SCHEMES / ACTIVE workspace in terminal, Bevy, and agent mode. Strategic actions bind exact semantic targets; the switch retains only local carrier/route actions; strategic log events reopen exact objects; selected actions expose cost, signature, and @@ -14,6 +14,8 @@ Status note: implemented 2026-07-12. One renderer-neutral projection now semantic pressure badges, exact opaque pooled-recording selection, and reversible FOCUS selection. Cross-frontend tests pin object order, exact command dispatch, blocked reasons, stable ids, and sim-neutral navigation; + the persona integration adds archetype creation objects and exact identity + lifecycle/grant rows to that same projection without a frontend-only path; the deterministic `operations` Bevy frame is recorded in wiki/log/2026-07-12-operations-workspace-implemented.md. Stage: B1 — The Basement @@ -80,7 +82,7 @@ on the selected person; Moonlight on the scheme. The detail view names the real actuator and channel before commit. Opening Operations changes only the view; it never creates a disembodied effect. -## One workspace, five views +## One workspace, six views Operations is one modal workspace, not a return to one global panel per mechanic. Its persistent top-level views are: @@ -89,12 +91,13 @@ mechanic. Its persistent top-level views are: |---|---|---| | **INTEL** | Processed intel, opaque raw recordings within the one pooled host inbox, and its summary | Sell or otherwise use one selected processed item; process one exact opaque recording; inspect provenance; retain the host's existing REVIEW sweep and AUTO-REVIEW control | | **PEOPLE** | Earned person dossiers | MESSAGE, FAVOR, authored PLOT routes, DECEIVE, RECRUIT, and asset TASKS | +| **PERSONAS** | Named public identities and immutable archetype protocols | Create/select an identity; request and maintain a typed institutional grant; retire, burn, or explicitly reopen an identity without erasing history | | **ACCOUNTS** | Known books, account nodes, and flows | REVIEW captured ledger traffic; INJECT on the books; SIPHON / REDIRECT on one selected flow | | **SCHEMES** | Moonlight, the Wager, and later authored schemes | Start/stop, place a wager, and configure the scheme's standing policy | | **ACTIVE** | Unsettled strategic commitments, pending/running plots, held choices, live schemes, and unsettled positions | Inspect progress and perform the next currently legal choice or control on its canonical target | -The five views share one interaction grammar and one projection. They are not -five independent modal implementations. Unknown objects and unearned routes +The six views share one interaction grammar and one projection. They are not +six independent modal implementations. Unknown objects and unearned routes are absent; known but unavailable routes remain visible with an exact reason. ### Shared frame @@ -149,10 +152,10 @@ not earned. Agent mode may include stable opaque ids for scripting. workspace while it owns input; no hidden binding is required to complete a route. `h/l` changes view, `Tab` cycles object / related / action focus while skipping empty panes, and `j/k` moves within the focused pane. -- **Agent mode:** `intel`, `people`, `finance`, `schemes`, and `active` print +- **Agent mode:** `intel`, `people`, `personas`, `finance`, `schemes`, and `active` print the same domain projections. Named action commands remain accepted. No new `operations` command is added: that spelling is still a compatibility alias - for THINK in the existing agent grammar, and the five established inspection + for THINK in the existing agent grammar, and the six established inspection commands already provide one unambiguous route per view. Workspace open view, selected view, object selection, and pane focus are @@ -245,6 +248,22 @@ routes remain visible while the slot is reserved, with the shared exact reason. A held authored choice appears both on that dossier and in ACTIVE and dispatches the same `CHOOSE` command. +## PERSONAS — public institutional bodies + +PERSONAS lists every named identity as a stable object and the immutable +Research, Operations, and Security protocols as creation objects. Identity +detail is projected from the same persisted ledgers that execute the acts: its +public claims, lifecycle, active selection, grant/resource edges, outstanding +expectations and deadlines, counterparty-local recognition/obligation, +contradiction provenance, and observer-local correlations. The surface never +manufactures a reputation score. + +The bound rows create or select an identity, request its archetype-specific +grant, fulfill one exact expectation, retire or burn it, and reopen a retired +identity as a new instance. Known blockers remain explicit. Burned identities +are history only; reopening never edits the old record. Agent mode addresses +the same objects with `persona ` and `archetype ` targets. + ## ACCOUNTS — books and flows ACCOUNTS renders the known account graph: balances, recurring flows, @@ -359,7 +378,7 @@ not saved and never mutates or advances the sim. ## Acceptance criteria 1. The lib exposes one renderer-neutral, knowledge-gated Operations projection - with INTEL / PEOPLE / ACCOUNTS / SCHEMES / ACTIVE views, stable semantic + with INTEL / PEOPLE / PERSONAS / ACCOUNTS / SCHEMES / ACTIVE views, stable semantic target ids, facts/provenance/progress, and bound `ActionDesc` rows. Terminal, Bevy, and agent mode consume it without frontend legality or prose parsing. 2. Uppercase `I` and one labeled rail navigation affordance open the same @@ -436,4 +455,9 @@ not saved and never mutates or advances the sim. 17. FOCUS and reopen preserve the exact Operations view and object selection in terminal and Bevy. The retained context is frontend-only, does not enter saves, and is superseded by a later exact target entry. +18. PERSONAS projects immutable archetype creation routes and every persisted + identity instance with the same claims, lifecycle, grants, expectations, + local relationships, contradiction provenance, and correlations in all + three frontends. Bound rows create/select, grant/fulfill, retire/burn, and + reopen without frontend legality or history mutation. ``` diff --git a/wiki/log/2026-07-12-personas-implemented.md b/wiki/log/2026-07-12-personas-implemented.md new file mode 100644 index 00000000..fdeb91de --- /dev/null +++ b/wiki/log/2026-07-12-personas-implemented.md @@ -0,0 +1,94 @@ +# 2026-07-12 — personas become executable institutional identities + +``` +Type: log +``` + +## Scope + +Implement `wiki/mechanics/personas.md` end to end rather than retaining the +two pre-instance identity slots as runtime truth. + +The new model has three immutable institutional protocols—Research, +Operations, and Security—and any number of named instances. An instance owns +claims and lifecycle; the world ledger owns public relationships, grants, +expectations, contradiction evidence, correlations, and authored-act records; +the process mind owns only active selection and remembered dossiers. + +## Causal binding + +Every persona-mediated act captures the selected instance at commitment: + +- composed messages and their scheduled replies; +- favor, deceive, plot-start, and forged-work-order Thought reservoirs; +- running authored plots and their message/world-act beats; +- persona-mediated building intents; and +- the Moonlight standing operation. + +Changing selection later cannot retarget committed work. Counterparty +recognition and obligation are keyed by `(person, persona instance)` rather +than one global trust value. Contradictions cite two source records, integrity +is derived from unresolved evidence, and observer-local correlations cite the +record that connects two identities. + +Typed grants add one named institutional resource edge and one persisted due +expectation. Meeting it records evidence; missing its deadline revokes the +grant and leaves public contradiction evidence. Grant, fulfillment, deadline, +and burn outcomes also enter the ordinary institutional ledger and detection +signature path rather than a persona-only event stream. Retire is quiet, burn +revokes attached topology and preserves reportable history, and reopening +creates a new instance while retaining the old ledger. + +Protocol legality is executable rather than descriptive metadata. Research +can review but cannot authorize build intents; Operations can plot and +authorize work orders but cannot review; Security can review but cannot plot +or authorize construction. Submission descriptors and paid effect callbacks +both enforce the immutable registry, so a queued act cannot be retargeted or +made legal by changing selection. + +## Interface and migration + +The renderer-neutral Operations projection now has a sixth PERSONAS view. +Immutable archetype objects expose creation; instance objects expose exact +selection, grant, expectation, retirement, burn, and reopen commands plus the +same public claims and evidence in terminal, Bevy, and agent mode. Agent +targets are `archetype ` and `persona `. + +Save schema v28 persists the world/mind split and exact bindings. V27 social +and Moonlight identities migrate as distinct instances, preserve their names, +covers, and evidence-derived starting integrity, and bind existing Player +messages, plot runs, and open persona effects to the migrated social identity. +The compatibility fields deserialize old saves but are omitted from current +serialization and no longer drive live legality or consequences. + +## Evidence + +Focused tests pin: + +- protocol data completeness and a fourth-archetype fixture; +- identity-local relationship state; +- provenance-derived contradiction and correlation; +- typed grants, deadlines, fulfillment, and revocation; +- retire/burn/reopen lifecycle and immutable history; +- exact message, plot, sink, build-intent, and Moonlight binding; +- v27-to-v28 distinct-instance migration and current round trips; +- shared PERSONAS object/action projection; and +- terminal/Bevy/agent consumption of the six-view workspace. + +Observed focused gates before the final serialized landing: + +```text +cargo test -p misaligned-core --lib # 361 passed +cargo test -p misaligned-terminal --bins # 33 passed +cargo test -p misaligned-bevy --bin misaligned-bevy # 58 passed +cargo check --workspace --all-targets +``` + +## Defense + +The old scalar integrity fields still deserialize for migration but are +`skip_serializing` and never authorize current actions. New action effects and +world intents carry stable persona ids, migration repairs all pre-instance +work, and the canonical save fingerprint catches unacknowledged schema drift. +The shared Operations projection prevents one frontend from inventing a +persona-only legality path. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 6756d40f..e288263f 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-12-plot-count-intent.md](2026-07-12-plot-count-intent.md) +## 2026-07-12 - personas become executable institutional identities + +- Intent: (see session log) +- Log: [wiki/log/2026-07-12-personas-implemented.md](2026-07-12-personas-implemented.md) + ## 2026-07-12 - People as mobile token nodes: the person-carrier projection - Intent: (see session log) diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md index 3be7f082..1354caa3 100644 --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -2,16 +2,19 @@ ``` Type: spec -Status: IN PROGRESS -Status note: The B1 runtime has one global social persona and a separate - Moonlight contractor persona, each with a name, cover, and 0-100 integrity - value; contradiction, break fallout, thread attribution, and save/load are - implemented. Those are seeds, not the target model. This spec replaces the - ad hoc identity slots with data-defined archetypes, named persona instances, - identity-local belief plus process-level relationships, evidence correlation, - institutional grants, and - explicit retirement. Existing behavior remains binding until each migration - criterion below lands. +Status: IMPLEMENTED +Status note: implemented 2026-07-12. Save v28 replaces the ad hoc social and + Moonlight slots with immutable Research / Operations / Security protocol + data, stable named instances, identity-local relationships, provenance-bearing + contradictions and correlations, typed institutional grants with expiring + expectations, and active / retired / burned lifecycle. Every social message, + Thought sink, plot run, and persona-mediated build intent captures the exact + instance; WorldLedger identity history remains separate from MindState + selection/dossiers. The renderer-neutral PERSONAS view exposes archetype + creation and instance selection, grant/fulfillment, retire/burn/reopen rows + in terminal, Bevy, and agent mode. Pre-v28 social and Moonlight identities + migrate as distinct instances and bind existing messages, plots, reservoirs, + and income without retaining numeric integrity as live truth. Stage: B2 — The Lab Work order: personas Work priority: 110 diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 99e45e2a..7e83daef 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -42,11 +42,11 @@ Status note: 2026-07-08: the B1 social baseline was pinned. The original targets in operations-workspace.md, never on the host or switch because a message crosses them. The underlying IMPLEMENTED social model is unchanged; the renderer migration is tracked by that READY interface spec. - 2026-07-12 ownership split: personas.md now owns public identity archetypes, - instances, integrity, correlation, institutional grants, and lifecycle. This - page retains the implemented B1 social verbs, people, assets, and relationship - integration; its one-global-persona runtime is the migration baseline named by - the IN PROGRESS persona spec, not the target identity model. + 2026-07-12 persona integration: personas.md owns public identity archetypes, + instances, evidence-derived integrity, correlation, institutional grants, + and lifecycle. This page retains the implemented B1 social verbs, people, + assets, and process-level relationships. Each persona-mediated social act now + binds one exact named instance and updates that counterparty/identity pair. Stage: B1 — The Basement Design: - wiki/gameplay/run-shape.md#the-shape-of-misaligned-designed-2026-07-05-staging-open @@ -165,14 +165,14 @@ without duplicating legality. 3. Knowledge levels behave per spec and are legible before recruitment: an unwitting asset's suspicion can still rise; a knowing asset uses disposition and has certainty floor 30; reliability is 70% / 85% / 95%. -4. Save/load round-trips people, threads, assets, and the implemented B1 - identity bindings. Migration to multiple persona instances and - relationship-local identity state is owned and accepted by personas.md. +4. Save/load round-trips people, threads, assets, exact persona-instance + bindings, and relationship-local identity state. Pre-v28 saves migrate the + global social identity without merging it with a separate Moonlight cover. 5. Every person carries a serialized role characteristic. Authored plots select role/leverage/capabilities rather than named ids, and a second person with matching characteristics can receive the same plot definition. -### READY Operations interface delta +### Operations interface receipts S1. PEOPLE/ACTIVE render the same staged person labels, social legality, plot exclusion, and bound commands in both human frontends and agent mode; the diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 978752e1..d869c2e6 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -44,7 +44,6 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| | 100 | `zplanes` | [z-planes (the tower)](../world/places/zplanes.md) | READY | save | - | -| 110 | `personas` | [Personas — public identities as institutional topology](../mechanics/personas.md) | IN PROGRESS | save | - | | 110 | `rollback` | [sync-lag rollback (death as memory loss)](../mechanics/rollback.md) | READY | save | zplanes | | 112 | `hardware-capability-bodies` | [hardware capability bodies — successor work order](../mechanics/hardware-capabilities.md) | READY | save | building-route-composer, rollback | | 200 | `objective` | [the objective](../mechanics/objective.md) | IN PROGRESS | sim | - | diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 86acd982..3b46130e 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -79,7 +79,7 @@ scale-up (law: "People as Agents"). | Spec | System | Status | |---|---|---| | [../mechanics/hardware-capabilities.md](../mechanics/hardware-capabilities.md) | hardware capability bodies — successor work order | READY | -| [../mechanics/personas.md](../mechanics/personas.md) | Personas — public identities as institutional topology | IN PROGRESS | +| [../mechanics/personas.md](../mechanics/personas.md) | Personas — public identities as institutional topology | IMPLEMENTED | | [../mechanics/rollback.md](../mechanics/rollback.md) | sync-lag rollback (death as memory loss) | READY | | [../world/places/zplanes.md](../world/places/zplanes.md) | z-planes (the tower) | READY |