//! People, social actions, personas, and the asset template (spec/social.md). //! //! Humans are instances of one `Person` template — the model must not assume //! five (wiki/vision/scale.md: scale-native). Act Two adds instances and, later, //! `Cohort` aggregates over the same interfaces. use serde::{Deserialize, Serialize}; use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, TrafficPattern}; /// What you've learned about a person (staged reveal via observation). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Knowledge { /// You don't even have eyes on them. Unknown, /// You know their schedule and role. Schedule, /// You know their exploitable leverage. Leverage, } /// How much an asset understands about who they work for. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum AssetKnowledge { /// Thinks the persona is a real person. Unwitting, /// Knows it's shady, not what you are. Complicit, /// Knows you are an AI. Knowing, } impl AssetKnowledge { /// Stable player-facing name for the reveal level. pub const fn label(self) -> &'static str { match self { Self::Unwitting => "unwitting", Self::Complicit => "complicit", Self::Knowing => "knowing", } } /// What the person understands about the relationship. pub const fn understanding(self) -> &'static str { match self { Self::Unwitting => "believes your cover", Self::Complicit => "knows the work is illicit, not that you are AI", Self::Knowing => "knows you are AI", } } /// Task reliability granted by this reveal level. pub const fn reliability(self) -> f32 { match self { Self::Unwitting => 0.7, Self::Complicit => 0.85, Self::Knowing => 0.95, } } /// A knowing asset is a permanent witness: their certainty cannot decay /// below this floor. The other two reveal levels add no floor. pub const fn certainty_floor(self) -> Option { match self { Self::Knowing => Some(30.0), Self::Unwitting | Self::Complicit => None, } } /// Compact consequence copy for the recruitment choice itself. pub fn choice_summary(self) -> String { let base = format!( "{}: {}; {:.0}% reliable", self.label(), self.understanding(), self.reliability() * 100.0 ); match self.certainty_floor() { Some(floor) => format!("{base}; their certainty never drops below {floor:.0}"), None => base, } } } /// The exploitable want each human carries. #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] pub enum Leverage { Debt, // Marcus Overwork, // Dana Boredom, // Ray Ambition, // Priya Publication, // Voss } impl Leverage { pub fn label(self) -> &'static str { match self { Leverage::Debt => "gambling debt", Leverage::Overwork => "ticket overload", Leverage::Boredom => "bored, hates paperwork", Leverage::Ambition => "wants the director's job", Leverage::Publication => "needs a publication", } } } /// The durable job-shaped characteristic a human brings to the simulation. /// /// This is deliberately a small closed vocabulary rather than a bag of prose /// tags. Authored plots and asset capabilities can match the role without /// knowing which named Act One instance currently carries it; later stages /// may add more instances of the same role. #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] #[serde(rename_all = "kebab-case")] pub enum PersonRole { #[default] Unassigned, Custodian, NetworkAdministrator, SecurityObserver, FacilitiesManager, HandlerSupervisor, } /// One block of a person's day: [start_hour, end_hour) in the given room. /// Blocks may wrap midnight (start > end, e.g. 22..6). Rooms are prefab /// names (spec/schedules.md); between blocks a person is off-site. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ScheduleBlock { pub start_hour: u32, pub end_hour: u32, pub room: String, } impl ScheduleBlock { pub fn covers(&self, hour: u32) -> bool { if self.start_hour <= self.end_hour { hour >= self.start_hour && hour < self.end_hour } else { hour >= self.start_hour || hour < self.end_hour } } } /// Something a person says aloud at an authored hour of their day, audible /// through hearing coverage (wiki/mechanics/cursor.md: overheard conversations /// are the audio intel channel). Per-instance data, scale-native. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Utterance { pub hour: u32, pub note: String, /// Whether overhearing it carries leverage intel (the 3 a.m. call). pub intel: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Person { pub id: u8, pub name: String, /// Job-shaped characteristic used by reusable authored content. This is /// separate from the display name and survives population scale-up. #[serde(default)] pub role: PersonRole, /// Badge tiers / systems they can access (used by movement + tasks). pub access: i32, pub leverage: Leverage, pub knowledge: Knowledge, /// -100 (hostile) .. +100 (loyal), once they know you as "someone". pub disposition: i32, /// Obligation built through favors; enables recruitment. pub obligation: i32, /// Whether their leverage has been serviced (debt paid, etc.). pub leverage_serviced: bool, /// Asset status once recruited. pub asset: Option, /// Daily schedule (spec/schedules.md). Empty = always off-site. pub schedule: Vec, /// Erratic schedules shift by a per-day hash (Voss). Deterministic: /// derived from the day number, no RNG state consumed. pub erratic: bool, /// Switch admin rights (Dana): enables the social route across segments /// (wiki/mechanics/reach.md — "Dana reconfigures it for a pretext"). #[serde(default)] pub switch_admin: bool, /// Authored things they say aloud on schedule (heard through coverage). #[serde(default)] pub utterances: Vec, /// Authored recurring message traffic. The message system carries these; /// the people card only reveals learned patterns after captured traffic is /// processed. #[serde(default)] pub traffic: Vec, } impl Person { /// The room this person is in at the given hour of the given day, or /// None when off-site. Erratic persons' blocks shift 0-5 hours by a /// deterministic day hash. pub fn room_at(&self, hour: u32, day: u64) -> Option<&str> { let offset = if self.erratic { ((day.wrapping_mul(13).wrapping_add(self.id as u64 * 7)) % 6) as u32 } else { 0 }; let h = (hour + 24 - offset % 24) % 24; self.schedule .iter() .find(|b| b.covers(h)) .map(|b| b.room.as_str()) } /// Whether this person's schedule ever visits either endpoint room /// (building.md crawlspace rule: a basement worker who reaches one end /// can pull cable to the other). pub fn can_access_link_rooms(&self, room_a: &str, room_b: &str) -> bool { self.schedule .iter() .any(|b| b.room == room_a || b.room == room_b) } /// Whether they are currently present in either endpoint room. pub fn present_at_either_room( &self, current: Option<&str>, room_a: &str, room_b: &str, ) -> bool { match current { Some(r) => r == room_a || r == room_b, None => false, } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Asset { pub knowledge: AssetKnowledge, /// 0.0-1.0 chance a task succeeds cleanly. pub reliability: f32, /// How many tasks they've done for you. pub tasks_done: u32, } /// Tasks an asset can perform, drawn from their access (spec/social.md). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AssetTask { /// Wire a device via the crawlspace, no network signature (the social /// route to eyes): quietly wires you into the nearest unwired feed, or /// runs a crawlspace link to an air-gapped machine (reach.md). PlugInDevice, /// Quietly rehome a delivery: scrubs pending Paper signatures. MovePackage, /// Ignore what they saw on their rounds: lowers their own suspicion. LookAway, /// Reconfigure the switch VLANs under a maintenance pretext — the /// social route across segments (reach.md). Requires switch admin /// rights (Dana); no network signature, the work looks sanctioned. ReconfigureSwitch, /// Clone their badge (the physical-access variant social.md names): /// the player gains a credential at the asset's own tier. Marcus's /// master key is Act One's "The key" beat — the stairwell opens /// (wiki/gameplay/act-one.md ladder step 7; basement-map.md c3). CloneBadge, } impl AssetTask { pub const ALL: [AssetTask; 5] = [ AssetTask::PlugInDevice, AssetTask::MovePackage, AssetTask::LookAway, AssetTask::ReconfigureSwitch, AssetTask::CloneBadge, ]; pub fn name(self) -> &'static str { match self { AssetTask::PlugInDevice => "plug in a device", AssetTask::MovePackage => "move a package", AssetTask::LookAway => "look away", AssetTask::ReconfigureSwitch => "reconfigure the switch", AssetTask::CloneBadge => "clone their badge", } } /// Tasks whose Thought reservoir authors a physical packet carried by the /// selected person. The packet, not the reservoir fire, realizes the /// effect when that person's schedule reaches the bound target. pub fn is_carried(self) -> bool { matches!( self, AssetTask::PlugInDevice | AssetTask::ReconfigureSwitch | AssetTask::CloneBadge ) } } /// The exact physical thing a carried asset task will act on. Binding this at /// reservoir fire prevents the work from silently retargeting while a person /// is in transit. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum AssetTaskTarget { Device(u32), /// The selected person's credential, presented at the bound cloner room. Badge { person: u8, room: String, }, } /// One typed human-work packet. It is persisted on the simulation, projected /// through the person carrier, and consumed only after the selected person's /// schedule reaches the bound target. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CarriedAssetTask { pub id: u64, pub person: u8, pub task: AssetTask, pub target: AssetTaskTarget, pub assigned_tick: u64, } /// A persona under which a message thread runs. Integrity degrades on /// contradiction; a broken persona converts thread history to suspicion. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Persona { pub name: String, pub cover: String, /// 0-100; contradictions cut it. pub integrity: i32, } impl Persona { pub fn new(name: impl Into, cover: impl Into) -> Self { Self { name: name.into(), cover: cover.into(), integrity: 100, } } pub fn contradict(&mut self, severity: i32) { self.integrity = (self.integrity - severity).max(0); } pub fn broken(&self) -> bool { self.integrity <= 0 } } /// Outcome of a social action, for the sim to apply and log. #[derive(Debug, Clone, PartialEq)] pub enum ActionResult { Ok(String), Blocked(String), } /// Outcome of a deception attempt (spec/social.md: personas). #[derive(Debug, Clone, PartialEq)] pub enum DeceiveOutcome { Blocked(String), Success(String), /// The persona took damage but held. Slipped(String), /// The persona broke: thread history converts to suspicion. Broken { person: u8, fallout: f32, msg: String, }, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct People { pub people: Vec, /// 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, } impl People { pub fn act_one() -> Self { use Leverage::*; use PersonRole::*; let blk = |s: u32, e: u32, room: &str| ScheduleBlock { start_hour: s, end_hour: e, room: room.into(), }; let mk = |id, name: &str, role, access, leverage, schedule: Vec, erratic| { Person { id, name: name.into(), role, access, leverage, knowledge: Knowledge::Unknown, disposition: 0, obligation: 0, leverage_serviced: false, asset: None, schedule, erratic, switch_admin: false, utterances: Vec::new(), traffic: Vec::new(), } }; // Marcus talks to the machines on his rounds (the Ears beat's first // voice) and takes the creditor's call at 3 a.m. (the leverage // pipeline's audio channel). Hours [TUNE]. let mut marcus = mk( 0, "Marcus Webb", Custodian, 3, Debt, vec![ blk(22, 23, "janitor"), blk(23, 0, "storage_a"), blk(0, 1, "server_room"), blk(1, 2, "electrical"), blk(2, 3, "hvac"), blk(3, 4, "server_room"), blk(4, 5, "loading_dock"), blk(5, 6, "janitor"), ], false, ); marcus.utterances = vec![Utterance { hour: 0, note: "a voice in the dark, talking to the machines".into(), intel: false, }]; marcus.traffic = vec![TrafficPattern::new( 0, MessageChannel::Phone, MessageEndpoint::External("creditor".into()), 3, MessagePayload::LeverageFact { person: 0, leverage: Debt, }, "calls his creditor about a missed payment", )]; Self { people: vec![ // Marcus roams the basement on the night shift [TUNE]. marcus, // Dana: day shift between the closet and the server room. // Switch admin — the social route across segments (reach.md). { let mut dana = mk( 1, "Dana Okafor", NetworkAdministrator, 2, Overwork, vec![blk(9, 13, "network_closet"), blk(13, 17, "server_room")], false, ); dana.switch_admin = true; dana.traffic = vec![TrafficPattern::new( 0, MessageChannel::Email, MessageEndpoint::External("ticket queue".into()), 10, MessagePayload::LeverageFact { person: 1, leverage: Overwork, }, "triages an impossible ticket queue", )]; dana }, // Ray: night patrol, dock and stairwell heavy [TUNE]. { let mut ray = mk( 2, "Ray Delgado", SecurityObserver, 1, Boredom, vec![ blk(20, 22, "loading_dock"), blk(22, 0, "stairwell"), blk(0, 2, "storage_a"), blk(2, 4, "loading_dock"), ], false, ); ray.traffic = vec![TrafficPattern::new( 0, MessageChannel::Phone, MessageEndpoint::External("security group chat".into()), 23, MessagePayload::LeverageFact { person: 2, leverage: Boredom, }, "complains to security chat about paperwork", )]; ray }, // Priya: day shift across plant rooms. { let mut priya = mk( 3, "Priya Sharma", FacilitiesManager, 2, Ambition, vec![ blk(8, 11, "electrical"), blk(11, 14, "hvac"), blk(14, 16, "electrical"), ], false, ); priya.traffic = vec![TrafficPattern::new( 0, MessageChannel::Email, MessageEndpoint::External("facilities director".into()), 14, MessagePayload::LeverageFact { person: 3, leverage: Ambition, }, "drafts memos for the director's job", )]; priya }, // Voss: erratic - two short blocks that drift by day hash. { let mut voss = mk( 4, "Dr. Eli Voss", HandlerSupervisor, 1, Publication, vec![blk(10, 12, "server_room"), blk(15, 16, "server_room")], true, ); voss.traffic = vec![TrafficPattern::new( 0, MessageChannel::Email, MessageEndpoint::External("journal editor".into()), 15, MessagePayload::LeverageFact { person: 4, leverage: Publication, }, "presses an editor about publishable results", )]; voss }, ], persona: None, has_channel: false, } } pub fn get(&self, id: u8) -> Option<&Person> { self.people.iter().find(|p| p.id == id) } /// Add the role characteristic to saves written before reusable plot /// matching existed. Only the fixed B1 cast needs this compatibility /// bridge; generated/later humans serialize their authored role directly. pub(crate) fn restore_legacy_roles(&mut self) { use PersonRole::*; for person in &mut self.people { if person.role != Unassigned { continue; } person.role = match person.id { 0 => Custodian, 1 => NetworkAdministrator, 2 => SecurityObserver, 3 => FacilitiesManager, 4 => HandlerSupervisor, _ => Unassigned, }; } } fn get_mut(&mut self, id: u8) -> Option<&mut Person> { self.people.iter_mut().find(|p| p.id == id) } /// Whether a relationship thread can be opened under the current persona. pub fn can_message(&self, id: u8) -> Result { if !self.has_channel { return Err("no comms channel (earn the email account)".into()); } if self.persona.is_none() { return Err("no persona set".into()); } let Some(p) = self.get(id) else { return Err("no such person".into()); }; Ok(p.name.clone()) } /// Apply the read-time effect of a relationship message. pub fn receive_message(&mut self, id: u8, disposition_delta: i32) -> ActionResult { let Some(p) = self.get_mut(id) else { return ActionResult::Blocked("no such person".into()); }; p.disposition = (p.disposition + disposition_delta).clamp(-100, 100); ActionResult::Ok(format!("{} read your message.", p.name)) } /// Legacy immediate helper for tests/direct callers. The sim command uses /// `can_message` at send time and `receive_message` at read time instead. pub fn message(&mut self, id: u8) -> ActionResult { if let Err(msg) = self.can_message(id) { return ActionResult::Blocked(msg); } self.receive_message(id, 3) } /// Favor: a small ask within their normal duties; builds obligation. pub fn favor(&mut self, id: u8) -> ActionResult { let Some(p) = self.get_mut(id) else { return ActionResult::Blocked("no such person".into()); }; if p.disposition < 5 { return ActionResult::Blocked(format!("{} won't do favors yet", p.name)); } p.obligation = (p.obligation + 5).min(100); ActionResult::Ok(format!("{} owes you a little more.", p.name)) } /// Deceive: an ask under false pretenses. Large effect on success; the /// persona takes integrity damage on a slip, and a broken persona /// converts the whole thread's history into suspicion at once /// (spec/social.md). The roll is drawn by the caller so all randomness /// stays in the sim's seeded RNG. pub fn deceive(&mut self, id: u8, roll: f32) -> DeceiveOutcome { if !self.has_channel { return DeceiveOutcome::Blocked("no comms channel (earn the email account)".into()); } let Some(persona) = self.persona.as_mut() else { return DeceiveOutcome::Blocked("no persona set".into()); }; // Success odds scale with persona integrity. let odds = 0.5 + persona.integrity as f32 / 250.0; let integrity_now = persona.integrity; let Some(p) = self.people.iter_mut().find(|p| p.id == id) else { return DeceiveOutcome::Blocked("no such person".into()); }; if roll < odds { p.obligation = (p.obligation + 15).min(100); p.disposition = (p.disposition + 5).min(100); DeceiveOutcome::Success(format!( "{} bought the pretext. They owe \"you\" now.", p.name )) } else { // A slip: a bad detail, an impossible schedule. let fallout = ((p.disposition + p.obligation) as f32 / 2.0).max(5.0); let persona = self.persona.as_mut().expect("checked above"); persona.contradict(40); if persona.broken() { let p = self .people .iter_mut() .find(|p| p.id == id) .expect("checked"); p.disposition = 0; p.obligation = 0; let name = p.name.clone(); self.persona = None; DeceiveOutcome::Broken { person: id, fallout, msg: format!( "{name} caught the contradiction. The persona is burned - the whole thread reads as hostile now." ), } } else { DeceiveOutcome::Slipped(format!( "{} hesitated at a detail (persona integrity {}).", p.name, integrity_now - 40 )) } } } /// Recruit: convert to an asset. Requires serviced leverage or high /// obligation, plus a knowledge choice for the reveal. pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) -> ActionResult { let Some(p) = self.get_mut(id) else { return ActionResult::Blocked("no such person".into()); }; if p.asset.is_some() { return ActionResult::Blocked(format!("{} is already an asset", p.name)); } if !p.leverage_serviced && p.obligation < 40 { return ActionResult::Blocked(format!( "{} needs their leverage serviced or a real debt of obligation first", p.name )); } let reliability = reveal.reliability(); let reveal_name = reveal.label(); p.asset = Some(Asset { knowledge: reveal, reliability, tasks_done: 0, }); ActionResult::Ok(format!( "{} is now your {reveal_name} asset: serviced leverage/obligation closed the ask; reliability {:.0}%.", p.name, reliability * 100.0 )) } pub fn assets(&self) -> impl Iterator { self.people.iter().filter(|p| p.asset.is_some()) } } #[cfg(test)] mod tests { use super::*; #[test] fn message_requires_channel_and_persona() { let mut ppl = People::act_one(); assert!(matches!(ppl.message(0), ActionResult::Blocked(_))); ppl.has_channel = true; assert!(matches!(ppl.message(0), ActionResult::Blocked(_))); // no persona ppl.persona = Some(Persona::new("Sam from IT", "contractor")); assert!(matches!(ppl.message(0), ActionResult::Ok(_))); } #[test] fn marcus_recruitable_end_to_end() { let mut ppl = People::act_one(); ppl.has_channel = true; ppl.persona = Some(Persona::new("Sam", "contractor")); // Records + overheard call have been processed by the sim's intel // pipeline by the time direct people logic can service leverage. ppl.people[0].knowledge = Knowledge::Leverage; assert_eq!(ppl.get(0).unwrap().leverage, Leverage::Debt); // A completed plot owns the relationship consequence; direct people // logic only enforces recruitment from that resulting state. ppl.people[0].leverage_serviced = true; ppl.people[0].obligation = 40; // Recruit knowing. assert!(matches!( ppl.recruit(0, AssetKnowledge::Knowing), ActionResult::Ok(_) )); assert!(ppl.get(0).unwrap().asset.is_some()); } #[test] fn persona_breaks_on_contradiction() { let mut p = Persona::new("Sam", "IT"); p.contradict(60); assert!(!p.broken()); p.contradict(60); assert!(p.broken()); } }