diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 26160ce2..7d2f4a0a 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -610,6 +610,7 @@ const BEVY_SHOT_KINDS: &[&str] = &[ "operations-intel", "operations-links", "operations-people", + "operations-persona-new", "operations-personas", "operator-pressure", "origin-picker", diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index bd27adbc..417fb5a8 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -645,6 +645,9 @@ pub(super) fn ops_pointer( if let Some(ops) = &mut game.ops && ops.pane == OpsPane::Objects && ops.confirm.is_none() + // The object rail is context while the identity-creation + // screen is open; hovering it must not retarget the draft. + && ops.draft.is_none() { ops.select_object_index(&game.sim, row.index); } @@ -653,6 +656,9 @@ pub(super) fn ops_pointer( { let game = &mut *game; if let Some(ops) = &mut game.ops { + if ops.draft.is_some() { + continue; + } ops.pane = OpsPane::Objects; ops.confirm = None; ops.select_object_index(&game.sim, row.index); @@ -695,6 +701,7 @@ pub(super) fn ops_pointer( let game = &mut *game; if let Some(ops) = &mut game.ops && ops.confirm.is_none() + && ops.draft.is_none() { ops.pane = OpsPane::Related; ops.select_related_index(&game.sim, row.index); @@ -704,6 +711,9 @@ pub(super) fn ops_pointer( { let game = &mut *game; if let Some(ops) = &mut game.ops { + if ops.draft.is_some() { + continue; + } ops.pane = OpsPane::Related; ops.confirm = None; ops.select_related_index(&game.sim, row.index); @@ -840,6 +850,10 @@ pub(super) fn manage_operations_ui( let projection = sim.operations_projection(); let objects = ops.objects(sim); let selected_object = ops.selected_index(sim).unwrap_or(0); + // The detail and action panes describe whatever the workspace says is + // selected, which an open identity-creation screen answers for itself. The + // rail keeps its own index so the roster stays visible behind the screen. + let detail = ops.selected_object(sim); let entries = ops.action_entries(sim); let selected_action = ops.selected_action_index(sim).unwrap_or(0); let selected_related = ops.selected_related_index(sim).unwrap_or(0); @@ -1091,12 +1105,12 @@ pub(super) fn manage_operations_ui( BackgroundColor(Color::NONE), BorderColor::all(scaled(GUNMETAL, 0.62)), )) - .with_children(|detail| { - let Some(obj) = objects.get(selected_object) else { - spawn_ops_section_label(detail, "NOTHING HERE YET"); + .with_children(|detail_pane| { + let Some(obj) = detail.as_ref() else { + spawn_ops_section_label(detail_pane, "NOTHING HERE YET"); return; }; - spawn_ops_object_body(detail, obj, &ops, selected_related); + spawn_ops_object_body(detail_pane, obj, &ops, selected_related); }); body.spawn(( @@ -1116,7 +1130,7 @@ pub(super) fn manage_operations_ui( BackgroundColor(scaled(NEAR_BLACK, 0.18)), )) .with_children(|actions| { - let Some(obj) = objects.get(selected_object) else { + let Some(obj) = detail.as_ref() else { spawn_ops_section_label(actions, "NO ACTIONS"); return; }; @@ -1541,6 +1555,9 @@ fn ops_action_entry_line(entry: &OpsActionEntry) -> String { format!("{indent}{label}") } OpsActionEntry::Submenu { label, .. } => format!("{label} >"), + // Identity-creation controls already carry their own marker for the + // current protocol; they print as written. + OpsActionEntry::Draft { label, .. } => label.clone(), } } diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 216b0fe9..1c670eff 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -861,9 +861,12 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s mode.zoom = 2.0; return; } - // Protocol-local PERSONAS hierarchy: each archetype owns its instances - // and its creation footer instead of contributing to two flat blocks. - if kind == "operations-personas" { + // The PERSONAS roster: every identity the player owns, grouped by protocol, + // closed by one creation row. Creation controls no longer interleave with + // the roster. + // `operations-persona-new` is the same rail with the identity-creation + // screen open over it. + if kind == "operations-personas" || kind == "operations-persona-new" { for archetype_id in [ "research", "research", @@ -873,10 +876,32 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s ] { game.sim.execute_action(&ActionCommand::CreatePersona { archetype_id: archetype_id.into(), + name: None, }); } dev_clear_teaching_lock(game); - game.ops = Some(OperationsWorkspace::open_view(OperationsView::Personas)); + let mut ops = OperationsWorkspace::open_view(OperationsView::Personas); + if kind == "operations-persona-new" { + let index = ops + .objects(&game.sim) + .iter() + .position(|object| object.target == OperationsTarget::PersonaDraft) + .expect("the creation row closes the rail"); + ops.select_object_index(&game.sim, index); + ops.select(&game.sim); + // Rest on OPERATIONS so the frame shows a protocol adopted and its + // consequence read, not only the opening default. + let protocol = ops + .action_entries(&game.sim) + .iter() + .position(|entry| { + entry.draft_control() + == Some(misaligned::operations_ui::PersonaDraftControl::Protocol(1)) + }) + .expect("the screen prints its protocol comparison"); + ops.select_action_index(&game.sim, protocol); + } + game.ops = Some(ops); game.drain(); mode.material = true; mode.zoom = 2.0; @@ -1359,6 +1384,7 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s if kind == "recruit-menu" { game.sim.execute_action(&ActionCommand::CreatePersona { archetype_id: "operations".into(), + name: None, }); let person = 1; game.sim.people.people[person as usize].knowledge = Knowledge::Leverage; diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 2ae0d749..c6383975 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -208,6 +208,10 @@ pub enum ActionCommand { }, CreatePersona { archetype_id: String, + /// The label chosen on the identity-creation screen. `None` takes the + /// deterministic suggestion the screen opened on. A name buys no reach + /// and no history: legality still comes entirely from `archetype_id`. + name: Option, }, RequestPersonaGrant(crate::persona::PersonaId), MeetPersonaExpectation { @@ -713,7 +717,7 @@ impl ActionKind { Action, Live, [Identity], - "act archetype ", + "act persona new", [], "create a named institutional identity from the PERSONAS view" ), @@ -2013,8 +2017,8 @@ impl Sim { ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), ActionCommand::Eliminate { actor, target } => self.eliminate(*actor, *target), - ActionCommand::CreatePersona { archetype_id } => { - self.create_persona(archetype_id); + ActionCommand::CreatePersona { archetype_id, name } => { + self.create_persona(archetype_id, name.as_deref()); } ActionCommand::RequestPersonaGrant(id) => { self.request_persona_grant(*id); @@ -5482,6 +5486,7 @@ mod tests { let person = 1; s.execute_action(&ActionCommand::CreatePersona { archetype_id: "operations".into(), + name: None, }); s.people.people[person as usize].knowledge = Knowledge::Schedule; let anchor = Anchor::Person(person); diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 8dae55f0..8939bb1a 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -77,7 +77,13 @@ pub enum OperationsTarget { /// One named public identity (personas.md). Persona(PersonaId), /// One immutable public-identity protocol available for instantiation. + /// Reachable as the identity-creation screen's per-protocol read; it is not + /// a top-level row, so creation controls never split into three columns. PersonaArchetype(String), + /// The single creation row that closes the PERSONAS rail. Selecting it + /// opens the identity-creation screen, where protocol and name are chosen + /// before anything is established. + PersonaDraft, /// One known account node (economy.md). Account(u32), /// The captured Lab books / ledger (economy.md). @@ -301,9 +307,9 @@ impl OperationsTarget { OperationsTarget::Person(_) | OperationsTarget::AssuranceOffice => { OperationsView::People } - OperationsTarget::Persona(_) | OperationsTarget::PersonaArchetype(_) => { - OperationsView::Personas - } + OperationsTarget::Persona(_) + | OperationsTarget::PersonaArchetype(_) + | OperationsTarget::PersonaDraft => OperationsView::Personas, OperationsTarget::Account(_) | OperationsTarget::Books | OperationsTarget::Flow(_) => { OperationsView::Accounts } @@ -340,6 +346,7 @@ impl OperationsTarget { OperationsTarget::Person(id) => Some(format!("@person({id})")), OperationsTarget::Persona(id) => Some(format!("@persona({id})")), OperationsTarget::PersonaArchetype(id) => Some(format!("@archetype({id})")), + OperationsTarget::PersonaDraft => Some("@persona(new)".into()), OperationsTarget::Account(id) => Some(format!("@account({id})")), OperationsTarget::Books => Some("@account(books)".into()), OperationsTarget::Flow(id) => Some(format!("@flow({id})")), @@ -430,6 +437,14 @@ impl Sim { OperationsTarget::IntelCustody { node_id } => { return self.intel_custody_object(*node_id); } + // A protocol is reachable by name without being a PERSONAS row: + // creation collapsed to one row at the foot of the rail, and each + // protocol's read now lives inside the identity-creation screen and + // behind agent mode's `@archetype()`. + OperationsTarget::PersonaArchetype(id) => { + return persona::archetype(id) + .map(|definition| self.persona_archetype_object(definition)); + } _ => {} } let projection = self.operations_projection(); @@ -1801,6 +1816,88 @@ impl Sim { // ── PERSONAS ─────────────────────────────────────────────────────────── + /// One protocol's full read, as the identity-creation screen shows it and + /// as agent mode reaches it through `@archetype()`. This is not a + /// top-level PERSONAS row: creation is one row at the foot of the rail, and + /// the protocol comparison lives inside it. + pub(crate) fn persona_archetype_object( + &self, + definition: &persona::PersonaArchetype, + ) -> OperationsObject { + OperationsObject { + learned_result: None, + consequence: None, + target: OperationsTarget::PersonaArchetype(definition.id.into()), + label: definition.label.to_ascii_uppercase(), + state: ObjectState::Available, + provenance: vec!["a role institutions already recognize".into()], + facts: { + let mut facts = vec![format!( + "the world would read it as {}", + persona_reads_as(definition.id) + )]; + facts.extend(persona_reach_facts(definition.available_actions)); + facts.push(format!( + "if an institution grants it anything, it owes: {}", + definition.expectation + )); + facts.push("a new name starts with no history and no counterparties".into()); + facts + }, + 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(), + // The bound row carries no name, so an agent or a bare + // dispatch takes the same deterministic suggestion the + // human screen opens on. The screen substitutes the + // player's choice on this exact command; it never invents + // a second legality or execution path. + name: None, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: None, + automate: None, + }], + } + } + + /// The single creation row that closes the PERSONAS rail. It carries one + /// bound creation command per protocol — the complete inventory agent mode + /// consumes flat — and links to each protocol's full read. Human frontends + /// open the identity-creation screen over these same exact rows. + fn persona_draft_object(&self) -> OperationsObject { + OperationsObject { + learned_result: None, + consequence: None, + target: OperationsTarget::PersonaDraft, + label: "+ ESTABLISH NEW IDENTITY...".into(), + state: ObjectState::Available, + provenance: vec!["a role institutions already recognize".into()], + facts: vec![ + "choose the protocol the world reads, then the name it reads it under".into(), + "a new identity starts with no history and no counterparties".into(), + "nothing is established until the screen commits it".into(), + ], + progress: Vec::new(), + related: persona::PERSONA_ARCHETYPES + .iter() + .map(|definition| OperationsLink { + relation: "protocol", + label: definition.label.to_ascii_uppercase(), + target: OperationsTarget::PersonaArchetype(definition.id.into()), + }) + .collect(), + actions: persona::PERSONA_ARCHETYPES + .iter() + .flat_map(|definition| self.persona_archetype_object(definition).actions) + .collect(), + } + } + fn persona_observer_label(&self, observer: u8) -> String { if observer == crate::income::MOONLIGHT_CLIENT_ID { "Moonlight client".into() @@ -1839,69 +1936,6 @@ impl Sim { } fn personas_view(&self) -> Vec { - // Human copy for the protocol verbs. `None` marks a verb the archetype - // registry declares but no world system honors yet; action-vocabulary.md - // keeps stub entries off every player surface until they are playable, - // so an unhonored verb never reaches a human `can:`/`cannot:` line. It - // stays on the agent-facing registry and on the bound command. - fn act_phrase(action: PersonaActionKind) -> Option<&'static str> { - match action { - PersonaActionKind::Message => Some("send messages"), - PersonaActionKind::Request => Some("make institutional requests"), - PersonaActionKind::Deceive => Some("lie to a counterparty"), - PersonaActionKind::Plot => Some("run an authored plot"), - PersonaActionKind::BuildIntent => Some("order physical work"), - PersonaActionKind::Review => None, - } - } - - // Stable presentation order, widest protocol reach last, so the - // `can:`/`cannot:` split reads the same on every archetype. - const ACT_ORDER: &[PersonaActionKind] = &[ - PersonaActionKind::Message, - PersonaActionKind::Request, - PersonaActionKind::Deceive, - PersonaActionKind::Plot, - PersonaActionKind::BuildIntent, - PersonaActionKind::Review, - ]; - - // What the protocol opens and what it closes, against the same fixed - // list every time, so the player can compare two identities directly - // instead of inferring absence from a registry dump. - fn reach_facts(available: &[PersonaActionKind]) -> Vec { - let (mut can, mut cannot) = (Vec::new(), Vec::new()); - for action in ACT_ORDER { - let Some(phrase) = act_phrase(*action) else { - continue; - }; - if available.contains(action) { - can.push(phrase); - } else { - cannot.push(phrase); - } - } - let mut out = Vec::new(); - if !can.is_empty() { - out.push(format!("can: {}", can.join(", "))); - } - if !cannot.is_empty() { - out.push(format!("cannot: {}", cannot.join(", "))); - } - out - } - - // The standing institutional reading each protocol buys before the - // identity has any history of its own. - fn reads_as(archetype_id: &str) -> &'static str { - match archetype_id { - "research" => "a lab collaborator or analyst", - "operations" => "a contractor or service desk", - "security" => "an auditor or incident responder", - _ => "an outside party", - } - } - // What the counterparty has worked out about the mask, in their words. fn discovery_phrase(discovery: PersonaDiscovery) -> &'static str { match discovery { @@ -1919,7 +1953,10 @@ impl Sim { .into_iter() .map(|instance| { let mut facts = vec![ - format!("the world reads it as {}", reads_as(&instance.archetype_id)), + format!( + "the world reads it as {}", + persona_reads_as(&instance.archetype_id) + ), match instance.lifecycle { PersonaLifecycle::Active => { // Criterion 13: identity is bound per relationship, @@ -1951,7 +1988,7 @@ impl Sim { for claim in &instance.claims { facts.push(format!("claims {}: {}", claim.key, claim.value)); } - facts.extend(reach_facts(&instance.available_actions)); + facts.extend(persona_reach_facts(&instance.available_actions)); for grant in self .persona_world .grants @@ -2197,6 +2234,11 @@ impl Sim { } }) .collect::>(); + // Every identity the player already owns reads first, still grouped by + // protocol in the fixed Research / Operations / Security order, so two + // masks of the same kind stay side by side. Creation is one row at the + // foot of the rail rather than three interleaved with the roster: the + // list answers "who am I already?" before it offers "who else?". let mut objects = Vec::new(); for definition in persona::PERSONA_ARCHETYPES { objects.extend( @@ -2212,43 +2254,8 @@ impl Sim { }) .cloned(), ); - objects.push(OperationsObject { - learned_result: None, - consequence: None, - target: OperationsTarget::PersonaArchetype(definition.id.into()), - label: format!( - "+ ADD NEW {} PERSONA...", - definition.label.to_ascii_uppercase() - ), - state: ObjectState::Available, - provenance: vec!["a role institutions already recognize".into()], - facts: { - let mut facts = vec![format!( - "the world would read it as {}", - reads_as(definition.id) - )]; - facts.extend(reach_facts(definition.available_actions)); - facts.push(format!( - "if an institution grants it anything, it owes: {}", - definition.expectation - )); - facts.push("a new name starts with no history and no counterparties".into()); - facts - }, - 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.push(self.persona_draft_object()); objects } @@ -2914,6 +2921,70 @@ impl Sim { } } +// Human copy for the protocol verbs. `None` marks a verb the archetype +// registry declares but no world system honors yet; action-vocabulary.md keeps +// stub entries off every player surface until they are playable, so an +// unhonored verb never reaches a human `can:`/`cannot:` line. It stays on the +// agent-facing registry and on the bound command. +fn persona_act_phrase(action: PersonaActionKind) -> Option<&'static str> { + match action { + PersonaActionKind::Message => Some("send messages"), + PersonaActionKind::Request => Some("make institutional requests"), + PersonaActionKind::Deceive => Some("lie to a counterparty"), + PersonaActionKind::Plot => Some("run an authored plot"), + PersonaActionKind::BuildIntent => Some("order physical work"), + PersonaActionKind::Review => None, + } +} + +// Stable presentation order, widest protocol reach last, so the `can:`/ +// `cannot:` split reads the same on every archetype. +const PERSONA_ACT_ORDER: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Deceive, + PersonaActionKind::Plot, + PersonaActionKind::BuildIntent, + PersonaActionKind::Review, +]; + +// What the protocol opens and what it closes, against the same fixed list +// every time, so the player can compare two identities directly instead of +// inferring absence from a registry dump. +fn persona_reach_facts(available: &[PersonaActionKind]) -> Vec { + let (mut can, mut cannot) = (Vec::new(), Vec::new()); + for action in PERSONA_ACT_ORDER { + let Some(phrase) = persona_act_phrase(*action) else { + continue; + }; + if available.contains(action) { + can.push(phrase); + } else { + cannot.push(phrase); + } + } + let mut out = Vec::new(); + if !can.is_empty() { + out.push(format!("can: {}", can.join(", "))); + } + if !cannot.is_empty() { + out.push(format!("cannot: {}", cannot.join(", "))); + } + out +} + +// The standing institutional reading each protocol buys before the identity +// has any history of its own. The identity-creation screen shares this exact +// copy, so a protocol is described the same way before and after it is worn. +pub(crate) fn persona_reads_as(archetype_id: &str) -> &'static str { + match archetype_id { + "research" => "a lab collaborator or analyst", + "operations" => "a contractor or service desk", + "security" => "an auditor or incident responder", + _ => "an outside party", + } +} + fn knowledge_label(k: Knowledge) -> &'static str { match k { Knowledge::Unknown => "unknown", @@ -4672,9 +4743,15 @@ mod tests { .operations_projection() .personas .iter() - .find(|object| object.target == OperationsTarget::PersonaArchetype("operations".into())) + .find(|object| object.target == OperationsTarget::PersonaDraft) + .unwrap() + .actions + .iter() + .find(|action| { + matches!(&action.command, ActionCommand::CreatePersona { archetype_id, .. } + if archetype_id == "operations") + }) .unwrap() - .actions[0] .command .clone(); s.execute_action(&create); @@ -4870,6 +4947,7 @@ mod tests { for archetype_id in ["research", "operations", "security"] { s.execute_action(&ActionCommand::CreatePersona { archetype_id: archetype_id.into(), + name: None, }); } let personas = s.operations_projection().personas; @@ -4898,13 +4976,10 @@ mod tests { } } + // Protocol reads live behind the creation row now, not on the rail. let row = |archetype_id: &str| { - personas - .iter() - .find(|object| { - object.target == OperationsTarget::PersonaArchetype(archetype_id.into()) - }) - .expect("archetype row") + s.operations_object(&OperationsTarget::PersonaArchetype(archetype_id.into())) + .expect("archetype read") }; assert!( row("operations") @@ -4925,13 +5000,17 @@ mod tests { ); } + /// The roster reads first and creation is one row at the foot of it. The + /// rail answers "who am I already?" before it offers "who else?", and the + /// protocol choice moved inside the creation screen. #[test] - fn personas_group_instances_by_archetype_with_add_row_last() { + fn personas_list_the_roster_first_and_close_with_one_creation_row() { let mut s = sim(); let mut created = Vec::new(); for archetype_id in ["research", "operations", "security"] { s.execute_action(&ActionCommand::CreatePersona { archetype_id: archetype_id.into(), + name: None, }); created.push(s.newest_persona_id().unwrap()); } @@ -4946,27 +5025,49 @@ mod tests { targets, vec![ OperationsTarget::Persona(created[0]), - OperationsTarget::PersonaArchetype("research".into()), OperationsTarget::Persona(created[1]), - OperationsTarget::PersonaArchetype("operations".into()), OperationsTarget::Persona(created[2]), + OperationsTarget::PersonaDraft, + ], + "instances group by protocol above one trailing creation row" + ); + + let draft = projection.personas.last().expect("the creation row"); + assert_eq!(draft.label, "+ ESTABLISH NEW IDENTITY..."); + // The complete creation inventory stays on the bound rows, so agent + // mode never needs the human screen to reach a protocol. + let offered = draft + .actions + .iter() + .filter_map(|action| match &action.command { + ActionCommand::CreatePersona { archetype_id, name } => { + assert_eq!(*name, None, "a bound row carries no pre-chosen name"); + Some(archetype_id.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!(offered, vec!["research", "operations", "security"]); + assert_eq!( + draft + .related + .iter() + .map(|link| link.target.clone()) + .collect::>(), + vec![ + OperationsTarget::PersonaArchetype("research".into()), + OperationsTarget::PersonaArchetype("operations".into()), OperationsTarget::PersonaArchetype("security".into()), - ] + ], + "each protocol's full read stays reachable from the creation row" ); - for (label, archetype) in [ - ("+ ADD NEW RESEARCH PERSONA...", "research"), - ("+ ADD NEW OPERATIONS PERSONA...", "operations"), - ("+ ADD NEW SECURITY PERSONA...", "security"), - ] { - let footer = projection + assert!( + !projection .personas .iter() - .find(|object| { - object.target == OperationsTarget::PersonaArchetype(archetype.into()) - }) - .unwrap(); - assert_eq!(footer.label, label); - } + .any(|object| matches!(object.target, OperationsTarget::PersonaArchetype(_))), + "creation controls never collect as separate rail rows" + ); } #[test] diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 36fcd3bb..0a9ef635 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -15,7 +15,9 @@ use crate::actions::{ActionCommand, MenuRow, menu_rows}; use crate::intel::IntelPolicyOutcome; -use crate::operations_projection::{OperationsObject, OperationsTarget, OperationsView}; +use crate::operations_projection::{ + OperationsObject, OperationsTarget, OperationsView, persona_reads_as, +}; use crate::person::AssetKnowledge; use crate::sim::Sim; @@ -51,9 +53,27 @@ pub enum OpsActionSubmenu { Recruitment, } +/// One control on the identity-creation screen. These are screen controls, not +/// world acts: only ESTABLISH resolves to a bound `ActionCommand`, and it +/// resolves to the exact `CreatePersona` row the projection already published +/// for the chosen protocol, carrying the chosen name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonaDraftControl { + /// Make this protocol the current one. Index into + /// [`crate::persona::PERSONA_ARCHETYPES`]. + Protocol(usize), + /// Draw the next deterministic name suggestion. + Reroll, + /// Commit: create the identity under the current protocol and name. + Establish, + /// Leave without establishing anything. + Cancel, +} + /// One visible row in the human Operations action pane. Direct actions retain /// their exact bound `MenuRow`; repeated implementation variants fold under a -/// plain-language intent row and open into the same exact rows one level down. +/// plain-language intent row and open into the same exact rows one level down; +/// identity-creation controls carry no command until they commit one. #[derive(Debug, Clone, PartialEq)] pub enum OpsActionEntry { /// A consequence-specific route into the exact choices this information @@ -74,21 +94,29 @@ pub enum OpsActionEntry { description: String, submenu: OpsActionSubmenu, }, + Draft { + label: String, + description: Option, + control: PersonaDraftControl, + }, } impl OpsActionEntry { pub fn label(&self) -> &str { match self { - Self::Open { label, .. } | Self::Action { label, .. } | Self::Submenu { label, .. } => { - label - } + Self::Open { label, .. } + | Self::Action { label, .. } + | Self::Submenu { label, .. } + | Self::Draft { label, .. } => label, } } pub fn description(&self) -> Option<&str> { match self { Self::Open { description, .. } => Some(description), - Self::Action { description, .. } => description.as_deref(), + Self::Action { description, .. } | Self::Draft { description, .. } => { + description.as_deref() + } Self::Submenu { description, .. } => Some(description), } } @@ -96,17 +124,152 @@ impl OpsActionEntry { pub fn row(&self) -> Option<&MenuRow> { match self { Self::Action { row, .. } => Some(row), - Self::Open { .. } | Self::Submenu { .. } => None, + Self::Open { .. } | Self::Submenu { .. } | Self::Draft { .. } => None, } } pub fn submenu(&self) -> Option { match self { - Self::Action { .. } => None, - Self::Open { .. } => None, + Self::Action { .. } | Self::Open { .. } | Self::Draft { .. } => None, Self::Submenu { submenu, .. } => Some(*submenu), } } + + pub fn draft_control(&self) -> Option { + match self { + Self::Draft { control, .. } => Some(*control), + Self::Action { .. } | Self::Submenu { .. } | Self::Open { .. } => None, + } + } +} + +/// The identity-creation screen's own state: which protocol is current, which +/// suggestion step the name came from, and whether the player has taken the +/// name into their own hands. +/// +/// This is frontend attention state like the rest of the workspace. Nothing +/// here enters the sim or the save, and nothing exists in the world until +/// ESTABLISH dispatches its bound command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonaDraft { + /// Index into [`crate::persona::PERSONA_ARCHETYPES`]. + pub protocol: usize, + /// Reroll step behind the current suggestion. + pub nonce: u64, + /// The name the identity would be established under. + pub name: String, + /// Set once the player rerolls. An untouched name follows the protocol, so + /// comparing protocols reads as one screen; a name the player chose is + /// theirs and survives a protocol change. + pub name_chosen: bool, +} + +impl PersonaDraft { + fn open(sim: &Sim) -> Self { + let protocol = 0; + Self { + protocol, + nonce: 0, + name: sim.suggested_persona_name(archetype_at(protocol).id, 0), + name_chosen: false, + } + } + + pub fn definition(&self) -> &'static crate::persona::PersonaArchetype { + archetype_at(self.protocol) + } + + fn set_protocol(&mut self, sim: &Sim, protocol: usize) { + if protocol >= crate::persona::PERSONA_ARCHETYPES.len() || protocol == self.protocol { + return; + } + self.protocol = protocol; + if !self.name_chosen { + self.name = sim.suggested_persona_name(self.definition().id, self.nonce); + } + } + + fn reroll(&mut self, sim: &Sim) { + self.nonce = self.nonce.wrapping_add(1); + self.name = sim.suggested_persona_name(self.definition().id, self.nonce); + self.name_chosen = true; + } + + /// The screen as one object: the current protocol's full read, retitled + /// with the name it would be established under. Both frontends render this + /// through their ordinary detail pane, so neither ships a bespoke modal. + pub fn object(&self, sim: &Sim) -> OperationsObject { + let definition = self.definition(); + let mut object = sim.persona_archetype_object(definition); + object.target = OperationsTarget::PersonaDraft; + object.label = format!( + "NEW IDENTITY · {} · {}", + definition.label.to_ascii_uppercase(), + self.name + ); + object + } + + /// The exact projected `CreatePersona` row for the current protocol, with + /// the chosen name substituted. Legality, cost, and signature come from the + /// projection verbatim; only the label the identity is established under is + /// the player's. + pub fn establish_row(&self, sim: &Sim) -> Option { + let definition = self.definition(); + let mut row = menu_rows(&sim.persona_archetype_object(definition).actions) + .into_iter() + .find(|row| matches!(row.command, ActionCommand::CreatePersona { .. }))?; + row.command = ActionCommand::CreatePersona { + archetype_id: definition.id.into(), + name: Some(self.name.clone()), + }; + row.label = format!("ESTABLISH {}", self.name.to_ascii_uppercase()); + Some(row) + } + + /// The screen's rows, in the order both frontends print them: the protocol + /// comparison with one current, the name, then commit and leave. + pub fn entries(&self, sim: &Sim) -> Vec { + let mut entries = Vec::new(); + for (index, definition) in crate::persona::PERSONA_ARCHETYPES.iter().enumerate() { + entries.push(OpsActionEntry::Draft { + label: format!( + "{} {}", + if index == self.protocol { ">" } else { " " }, + definition.label.to_ascii_uppercase() + ), + description: Some(format!("reads as {}", persona_reads_as(definition.id))), + control: PersonaDraftControl::Protocol(index), + }); + } + entries.push(OpsActionEntry::Draft { + label: format!("NAME {}", self.name), + description: Some("draw another suggestion".into()), + control: PersonaDraftControl::Reroll, + }); + entries.push(OpsActionEntry::Draft { + label: self + .establish_row(sim) + .map(|row| row.label) + .unwrap_or_else(|| "ESTABLISH".into()), + description: Some(format!( + "if an institution grants it anything, it owes: {}", + self.definition().expectation + )), + control: PersonaDraftControl::Establish, + }); + entries.push(OpsActionEntry::Draft { + label: "CANCEL".into(), + description: Some("leave without establishing anything".into()), + control: PersonaDraftControl::Cancel, + }); + entries + } +} + +fn archetype_at(index: usize) -> &'static crate::persona::PersonaArchetype { + let archetypes = crate::persona::PERSONA_ARCHETYPES; + &archetypes[index.min(archetypes.len() - 1)] } /// Exact semantic identity behind one visible action entry. Action and @@ -117,6 +280,7 @@ enum OpsActionKey { Open(OperationsTarget), Command(ActionCommand), Submenu(OpsActionSubmenu), + Draft(PersonaDraftControl), } impl OpsActionKey { @@ -125,6 +289,7 @@ impl OpsActionKey { OpsActionEntry::Open { target, .. } => Self::Open(target.clone()), OpsActionEntry::Action { row, .. } => Self::Command(row.command.clone()), OpsActionEntry::Submenu { submenu, .. } => Self::Submenu(*submenu), + OpsActionEntry::Draft { control, .. } => Self::Draft(*control), } } } @@ -173,6 +338,10 @@ pub struct OperationsWorkspace { /// link expands the selected thing without losing the exact place the /// player came from; Back restores that semantic parent before closing. trail: Vec, + /// The open identity-creation screen, if the PERSONAS creation row was + /// entered. While it is open it owns the detail pane: the object rail stays + /// visible for context, but rows describe the identity being drafted. + pub draft: Option, /// A pending target-bound confirmation for the selected action row. pub confirm: Option, /// Exact command snapshot shown when confirmation opened. The live row @@ -199,6 +368,7 @@ impl OperationsWorkspace { action: 0, selected_action_key: None, action_submenu: None, + draft: None, trail: Vec::new(), confirm: None, confirm_command: None, @@ -222,6 +392,7 @@ impl OperationsWorkspace { action: 0, selected_action_key: None, action_submenu: None, + draft: None, trail: Vec::new(), confirm: None, confirm_command: None, @@ -237,6 +408,7 @@ impl OperationsWorkspace { action: 0, selected_action_key: None, action_submenu: None, + draft: None, trail: Vec::new(), confirm: None, confirm_command: None, @@ -282,7 +454,12 @@ impl OperationsWorkspace { } /// The selected object, re-resolved by semantic target against the live list. + /// An open identity-creation screen answers for itself: the detail pane + /// describes the identity being drafted, not the generic creation row. pub fn selected_object(&self, sim: &Sim) -> Option { + if let Some(draft) = &self.draft { + return Some(draft.object(sim)); + } let objects = self.objects(sim); self.selected_index(sim) .and_then(|index| objects.get(index).cloned()) @@ -333,6 +510,9 @@ impl OperationsWorkspace { /// complete flat action inventory; Bevy and terminal consume these shared /// intent rows so neither frontend has to infer groups from display text. pub fn action_entries(&self, sim: &Sim) -> Vec { + if let Some(draft) = &self.draft { + return draft.entries(sim); + } self.selected_object(sim) .map(|object| self.action_entries_for_object(sim, &object)) .unwrap_or_default() @@ -482,6 +662,11 @@ impl OperationsWorkspace { self.selected_action_key = entries.get(self.action).map(OpsActionKey::of); self.confirm = None; self.confirm_command = None; + if self.draft.is_some() { + // Pointing at a protocol row adopts it, exactly as arrowing onto it + // does; the two input routes must not disagree about what is current. + self.settle_draft_cursor(sim); + } } /// Move the shared Operations cursor onto one exact bound command, @@ -565,6 +750,7 @@ impl OperationsWorkspace { self.action = 0; self.selected_action_key = None; self.action_submenu = None; + self.draft = None; self.trail.clear(); self.confirm = None; self.confirm_command = None; @@ -573,6 +759,12 @@ impl OperationsWorkspace { /// Move detail focus through causal links and actions without stealing /// h/l from the persistent view strip. Empty panes are skipped. pub fn next_pane(&mut self, sim: &Sim) { + // The identity-creation screen owns the detail pane while it is open; + // there is no second pane to tab into and no half-drafted identity to + // leave behind by tabbing away. + if self.draft.is_some() { + return; + } self.reconcile_selection(sim); self.confirm = None; self.confirm_command = None; @@ -591,6 +783,11 @@ impl OperationsWorkspace { } pub fn move_up(&mut self, sim: &Sim) { + if self.draft.is_some() { + self.action = self.action.saturating_sub(1); + self.settle_draft_cursor(sim); + return; + } self.reconcile_selection(sim); let previous = self.selected; match (self.pane, self.confirm) { @@ -617,6 +814,14 @@ impl OperationsWorkspace { } pub fn move_down(&mut self, sim: &Sim) { + if self.draft.is_some() { + self.action = self + .action + .saturating_add(1) + .min(self.action_entries(sim).len().saturating_sub(1)); + self.settle_draft_cursor(sim); + return; + } self.reconcile_selection(sim); let previous = self.selected; match (self.pane, self.confirm) { @@ -642,9 +847,34 @@ impl OperationsWorkspace { self.clamp_to(sim); } + /// Land the identity-creation cursor: resting on a protocol row makes that + /// protocol current, so the consequence read below always describes the row + /// the player is looking at (the origin picker's one-current grammar). + fn settle_draft_cursor(&mut self, sim: &Sim) { + let entries = self.action_entries(sim); + self.action = self.action.min(entries.len().saturating_sub(1)); + let control = entries + .get(self.action) + .and_then(OpsActionEntry::draft_control); + if let (Some(draft), Some(PersonaDraftControl::Protocol(index))) = + (self.draft.as_mut(), control) + { + draft.set_protocol(sim, index); + } + self.selected_action_key = self + .action_entries(sim) + .get(self.action) + .map(OpsActionKey::of); + } + /// Clamp selection to the live projection (frontends call this after /// pointer-driven selection so hover indexes never dangle). pub fn clamp_to(&mut self, sim: &Sim) { + // An open creation screen is not a projection row; the live rail cannot + // reorder it out from under the player mid-draft. + if self.draft.is_some() { + return; + } self.reconcile_selection(sim); let objects = self.objects(sim); self.selected = self.selected.min(objects.len().saturating_sub(1)); @@ -774,6 +1004,18 @@ impl OperationsWorkspace { /// Esc: one level back — confirmation → action submenu → objects → /// causal parent → close (returns true when the workspace should close). pub fn back(&mut self, sim: &Sim) -> bool { + // Esc leaves the creation screen without establishing anything. The + // draft is discarded whole: there is no half-created identity to + // recover, and reopening starts from a fresh suggestion. + if self.draft.take().is_some() { + self.pane = OpsPane::Objects; + self.action = 0; + self.selected_action_key = None; + self.confirm = None; + self.confirm_command = None; + self.clamp_to(sim); + return false; + } if self.confirm.is_some() { self.confirm = None; self.confirm_command = None; @@ -812,6 +1054,9 @@ impl OperationsWorkspace { /// Enter. Returns the interaction result; the caller owns sim mutation /// and logging so this state machine stays read-only over the sim. pub fn select(&mut self, sim: &Sim) -> OpsSelect { + if self.draft.is_some() { + return self.select_draft(sim); + } if let Some(choice) = self.confirm { let pending = self.confirm_command.take(); let row = self @@ -837,6 +1082,20 @@ impl OperationsWorkspace { } self.clamp_to(sim); match self.pane { + // The PERSONAS creation row is a screen, not an action list: it + // opens the identity-creation screen so protocol and name are one + // deliberate choice instead of three interleaved rail rows. + OpsPane::Objects + if self.selected_target == Some(OperationsTarget::PersonaDraft) + && self.focused.is_none() => + { + self.draft = Some(PersonaDraft::open(sim)); + self.pane = OpsPane::Actions; + self.action = 0; + self.selected_action_key = None; + self.settle_draft_cursor(sim); + OpsSelect::None + } OpsPane::Objects => { if !self.action_entries(sim).is_empty() { self.pane = OpsPane::Actions; @@ -867,7 +1126,11 @@ impl OperationsWorkspace { self.bind_selected_action(sim); OpsSelect::None } - Some(OpsActionEntry::Submenu { .. }) => OpsSelect::None, + // A draft control only reaches this arm if a frontend held a + // stale row across the screen closing; the screen owns them. + Some(OpsActionEntry::Submenu { .. } | OpsActionEntry::Draft { .. }) => { + OpsSelect::None + } Some(OpsActionEntry::Action { row, .. }) if row.disabled.is_some() => { // A disabled row stays selected and explains itself on // attempted execution (criterion 10). @@ -905,6 +1168,67 @@ impl OperationsWorkspace { }, } } + + /// Enter on the identity-creation screen. + /// + /// ESTABLISH is the screen's own commitment step, so it dispatches without + /// a second CONFIRM: the screen is already the deliberate choice, and + /// creating an identity is free, unsigned, and observed by nobody until it + /// acts (operations-workspace.md: confirmation follows consequence, not the + /// fact that a row was selected). + fn select_draft(&mut self, sim: &Sim) -> OpsSelect { + let entries = self.action_entries(sim); + let Some(control) = entries + .get(self.action.min(entries.len().saturating_sub(1))) + .and_then(OpsActionEntry::draft_control) + else { + return OpsSelect::None; + }; + match control { + PersonaDraftControl::Protocol(index) => { + if let Some(draft) = self.draft.as_mut() { + draft.set_protocol(sim, index); + } + // Confirming a protocol moves on to the name rather than + // sitting on a choice already made. + self.action = crate::persona::PERSONA_ARCHETYPES.len(); + self.settle_draft_cursor(sim); + OpsSelect::None + } + PersonaDraftControl::Reroll => { + if let Some(draft) = self.draft.as_mut() { + draft.reroll(sim); + } + self.settle_draft_cursor(sim); + OpsSelect::None + } + PersonaDraftControl::Establish => { + let Some(row) = self + .draft + .as_ref() + .and_then(|draft| draft.establish_row(sim)) + else { + return OpsSelect::None; + }; + if row.disabled.is_some() { + return OpsSelect::Blocked(row); + } + self.draft = None; + self.pane = OpsPane::Objects; + self.action = 0; + self.selected_action_key = None; + OpsSelect::Execute(row) + } + PersonaDraftControl::Cancel => { + self.draft = None; + self.pane = OpsPane::Objects; + self.action = 0; + self.selected_action_key = None; + self.clamp_to(sim); + OpsSelect::Cancelled + } + } + } } fn action_submenu_for_row(row: &MenuRow) -> Option { @@ -1574,3 +1898,236 @@ mod tests { assert_eq!(ops.pane, OpsPane::Objects); } } + +#[cfg(test)] +mod persona_draft_tests { + use super::*; + use crate::operations_projection::OperationsView; + + /// Open the PERSONAS view with its creation row selected. + fn creation_row(sim: &Sim) -> OperationsWorkspace { + let mut ops = OperationsWorkspace::open_view(OperationsView::Personas); + let index = ops + .objects(sim) + .iter() + .position(|object| object.target == OperationsTarget::PersonaDraft) + .expect("the creation row closes the rail"); + ops.select_object_index(sim, index); + ops + } + + fn row_index(ops: &OperationsWorkspace, sim: &Sim, control: PersonaDraftControl) -> usize { + ops.action_entries(sim) + .iter() + .position(|entry| entry.draft_control() == Some(control)) + .expect("the screen prints this control") + } + + /// The creation row is a screen, not an action list: one Enter opens the + /// protocol/name choice rather than dropping three CREATE rows on the + /// player. + #[test] + fn the_creation_row_opens_the_identity_screen() { + let sim = Sim::new(); + let mut ops = creation_row(&sim); + assert!(ops.draft.is_none()); + assert_eq!(ops.select(&sim), OpsSelect::None); + + let draft = ops.draft.as_ref().expect("the screen opened"); + assert_eq!(draft.definition().id, "research"); + assert!(!draft.name.is_empty()); + assert!( + ops.selected_object(&sim) + .expect("the screen answers as the detail object") + .label + .contains(&draft.name), + "the screen is titled with the identity it would establish" + ); + + let controls: Vec<_> = ops + .action_entries(&sim) + .iter() + .filter_map(OpsActionEntry::draft_control) + .collect(); + assert_eq!( + controls, + vec![ + PersonaDraftControl::Protocol(0), + PersonaDraftControl::Protocol(1), + PersonaDraftControl::Protocol(2), + PersonaDraftControl::Reroll, + PersonaDraftControl::Establish, + PersonaDraftControl::Cancel, + ] + ); + } + + /// Resting on a protocol makes it current, and the consequence read below + /// follows — the origin picker's one-current comparison grammar. + #[test] + fn moving_onto_a_protocol_adopts_it_and_its_consequence_read() { + let sim = Sim::new(); + let mut ops = creation_row(&sim); + ops.select(&sim); + + ops.select_action_index( + &sim, + row_index(&ops, &sim, PersonaDraftControl::Protocol(1)), + ); + assert_eq!(ops.draft.as_ref().unwrap().definition().id, "operations"); + let facts = ops.selected_object(&sim).unwrap().facts; + assert!( + facts + .iter() + .any(|fact| fact.contains("a contractor or service desk")), + "{facts:?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.starts_with("can: ") && fact.contains("order physical work")), + "{facts:?}" + ); + + ops.select_action_index( + &sim, + row_index(&ops, &sim, PersonaDraftControl::Protocol(2)), + ); + assert_eq!(ops.draft.as_ref().unwrap().definition().id, "security"); + assert!( + ops.selected_object(&sim) + .unwrap() + .facts + .iter() + .any(|fact| fact.starts_with("cannot: ") && fact.contains("order physical work")) + ); + } + + /// Rerolling draws a different suggestion, and a name the player chose + /// survives a protocol change while an untouched one follows the protocol. + #[test] + fn suggestions_reroll_and_a_chosen_name_outlives_a_protocol_change() { + let sim = Sim::new(); + let mut ops = creation_row(&sim); + ops.select(&sim); + let first = ops.draft.as_ref().unwrap().name.clone(); + + // An untouched name follows the protocol: the screen reads as one + // comparison, not three unrelated characters. + ops.select_action_index( + &sim, + row_index(&ops, &sim, PersonaDraftControl::Protocol(1)), + ); + assert_ne!(ops.draft.as_ref().unwrap().name, first); + assert!(!ops.draft.as_ref().unwrap().name_chosen); + + let before_reroll = ops.draft.as_ref().unwrap().name.clone(); + ops.select_action_index(&sim, row_index(&ops, &sim, PersonaDraftControl::Reroll)); + ops.select(&sim); + let chosen = ops.draft.as_ref().unwrap().name.clone(); + assert_ne!(chosen, before_reroll, "reroll draws another suggestion"); + assert!(ops.draft.as_ref().unwrap().name_chosen); + + ops.select_action_index( + &sim, + row_index(&ops, &sim, PersonaDraftControl::Protocol(2)), + ); + assert_eq!( + ops.draft.as_ref().unwrap().name, + chosen, + "a name the player chose is theirs across a protocol change" + ); + } + + /// The same run offers the same suggestions: nothing here reads a clock. + #[test] + fn suggestions_are_deterministic_for_the_same_run() { + let sim = Sim::new(); + let mut first = creation_row(&sim); + first.select(&sim); + let mut second = creation_row(&sim); + second.select(&sim); + assert_eq!( + first.draft.as_ref().unwrap().name, + second.draft.as_ref().unwrap().name + ); + assert_eq!( + sim.suggested_persona_name("operations", 3), + sim.suggested_persona_name("operations", 3) + ); + } + + /// ESTABLISH dispatches the exact projected creation command for the chosen + /// protocol, carrying the chosen name — and nothing exists before it. + #[test] + fn establish_dispatches_the_bound_command_with_the_chosen_name() { + let mut sim = Sim::new(); + let before = sim.persona_world.instances.len(); + let mut ops = creation_row(&sim); + ops.select(&sim); + ops.select_action_index( + &sim, + row_index(&ops, &sim, PersonaDraftControl::Protocol(1)), + ); + ops.select_action_index(&sim, row_index(&ops, &sim, PersonaDraftControl::Reroll)); + ops.select(&sim); + let name = ops.draft.as_ref().unwrap().name.clone(); + assert_eq!( + sim.persona_world.instances.len(), + before, + "drafting establishes nothing" + ); + + ops.select_action_index(&sim, row_index(&ops, &sim, PersonaDraftControl::Establish)); + let OpsSelect::Execute(row) = ops.select(&sim) else { + panic!("ESTABLISH is the screen's own commitment step"); + }; + assert_eq!( + row.command, + crate::actions::ActionCommand::CreatePersona { + archetype_id: "operations".into(), + name: Some(name.clone()), + } + ); + assert!(ops.draft.is_none(), "the screen closes on commit"); + + sim.execute_action(&row.command); + let created = sim.persona_world.instances.last().expect("established"); + assert_eq!(created.name, name); + assert_eq!(created.archetype_id, "operations"); + } + + /// CANCEL and Esc both leave without establishing anything. + #[test] + fn cancel_and_esc_leave_the_roster_untouched() { + let sim = Sim::new(); + let before = sim.persona_world.instances.len(); + + let mut ops = creation_row(&sim); + ops.select(&sim); + ops.select_action_index(&sim, row_index(&ops, &sim, PersonaDraftControl::Cancel)); + assert_eq!(ops.select(&sim), OpsSelect::Cancelled); + assert!(ops.draft.is_none()); + assert_eq!(ops.pane, OpsPane::Objects); + + ops.select(&sim); + assert!(ops.draft.is_some()); + assert!( + !ops.back(&sim), + "Esc closes the screen before the workspace" + ); + assert!(ops.draft.is_none()); + assert_eq!(sim.persona_world.instances.len(), before); + } + + /// Two live masks never share one name by accident. + #[test] + fn a_suggestion_never_repeats_a_name_already_worn() { + let mut sim = Sim::new(); + let taken = sim.suggested_persona_name("research", 0); + assert!(sim.create_persona("research", Some(&taken))); + for nonce in 0..8 { + assert_ne!(sim.suggested_persona_name("research", nonce), taken); + } + } +} diff --git a/crates/misaligned-core/src/persona.rs b/crates/misaligned-core/src/persona.rs index 41cdde76..c80c3283 100644 --- a/crates/misaligned-core/src/persona.rs +++ b/crates/misaligned-core/src/persona.rs @@ -157,6 +157,70 @@ pub const SECURITY_ARCHETYPE: PersonaArchetype = PersonaArchetype { pub const PERSONA_ARCHETYPES: &[PersonaArchetype] = &[RESEARCH_ARCHETYPE, OPERATIONS_ARCHETYPE, SECURITY_ARCHETYPE]; +/// Given and family names a suggested cover is drawn from. They are +/// deliberately unremarkable: the mask's whole value is reading as a person +/// nobody looks at twice, so the generator never produces a name that draws +/// attention to itself. Protocol does not flavor the pool — an identity's job +/// is carried by its claims, not by its parents' choice of name. +const SUGGESTED_GIVEN_NAMES: &[&str] = &[ + "Sam", "Aster", "Rowan", "Priya", "Dana", "Corin", "Noor", "Elias", "Mina", "Theo", "Ines", + "Kade", "Lena", "Marek", "Sena", "Jonah", "Rhea", "Oskar", "Talia", "Vance", "Nadia", "Emeric", + "Wren", "Bo", "Clara", "Idris", "Sasha", "Petra", "Linus", "Yara", +]; + +const SUGGESTED_FAMILY_NAMES: &[&str] = &[ + "Reyes", + "Kellin", + "Okonkwo", + "Aldridge", + "Ferrand", + "Nakamura", + "Voight", + "Salas", + "Brandt", + "Ivanova", + "Halloran", + "Descamps", + "Mardhani", + "Quist", + "Ellery", + "Norberg", + "Cattaneo", + "Bright", + "Odell", + "Sarkis", + "Wexley", + "Prieto", + "Lindqvist", + "Amari", + "Deshpande", + "Corliss", + "Vogel", + "Marchetti", + "Ashby", + "Tiernan", +]; + +/// One deterministic suggested name for a new identity. +/// +/// The caller supplies the seed from sim state — never the wall clock +/// (simulation-laws.md determinism): the same run at the same point offers the +/// same suggestion, and the same reroll step offers the same next one, so a +/// reloaded save and an agent-mode transcript agree on what the player saw. +/// The suggestion is a label only; it buys no reach and no history. +pub fn suggest_name(seed: u64) -> String { + // Mix first: sequential seeds (one reroll step apart) must not land on + // neighboring pool entries. + let mixed = seed + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .rotate_left(31) + .wrapping_mul(0xBF58_476D_1CE4_E5B9); + let mut rng = crate::rng::Rng::new(mixed); + let given = SUGGESTED_GIVEN_NAMES[rng.below(SUGGESTED_GIVEN_NAMES.len() as u32) as usize]; + let family = SUGGESTED_FAMILY_NAMES[rng.below(SUGGESTED_FAMILY_NAMES.len() as u32) as usize]; + format!("{given} {family}") +} + pub fn archetype(id: &str) -> Option<&'static PersonaArchetype> { PERSONA_ARCHETYPES .iter() diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 25590bdc..3fd77845 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -3770,7 +3770,7 @@ mod tests { sim.reconcile_work_grid(); sim.set_machine_mode(think, crate::work_grid::MachineMode::Think); sim.set_machine_mode(sim.core.host_machine, crate::work_grid::MachineMode::Work); - assert!(sim.create_persona("research")); + assert!(sim.create_persona("research", None)); for _ in 0..=Sim::DAY_TICKS + 1 { sim.advance(); } diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 86d3b257..92f76951 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -2549,25 +2549,53 @@ impl Sim { } impl Sim { + /// One deterministic suggested name for a not-yet-created identity of this + /// protocol, at reroll step `nonce`. + /// + /// The seed is drawn from sim state, so the identity-creation screen offers + /// the same suggestions on a reloaded save and in an agent transcript. A + /// suggestion already worn by an existing instance is skipped: two live + /// masks sharing one name is a correlation the player never chose. + pub fn suggested_persona_name(&self, archetype_id: &str, nonce: u64) -> String { + let protocol = crate::persona::PERSONA_ARCHETYPES + .iter() + .position(|definition| definition.id == archetype_id) + .unwrap_or(0) as u64; + let base = self + .persona_world + .next_persona_id + .wrapping_mul(1_000_003) + .wrapping_add(protocol.wrapping_mul(7_919)); + // Bounded: the pools are far larger than any run's instance count, so + // this settles in the first step or two. The cap keeps a pathological + // save from spinning rather than answering. + for step in 0..64 { + let name = crate::persona::suggest_name(base.wrapping_add(nonce.wrapping_add(step))); + if !self + .persona_world + .instances + .iter() + .any(|instance| instance.name == name) + { + return name; + } + } + crate::persona::suggest_name(base.wrapping_add(nonce)) + } + /// 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 { + /// protocol. `name` is the player's chosen label from the identity-creation + /// screen; `None` takes the deterministic suggestion that screen opened on, + /// so agent mode and a bare bound row create the same identity the human + /// surface would have offered first. + pub fn create_persona(&mut self, archetype_id: &str, name: Option<&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 name = match name.map(str::trim).filter(|name| !name.is_empty()) { + Some(chosen) => chosen.to_string(), + None => self.suggested_persona_name(archetype_id, 0), }; let claims = definition .required_claims diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index fd9a2708..647f0949 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -485,7 +485,7 @@ fn recipient_correlates_two_personas_that_reuse_one_reply_address() { } assert_eq!(sim.messages[0].status, MessageStatus::Read); - assert!(sim.create_persona("security")); + assert!(sim.create_persona("security", None)); let second = sim.newest_persona_id().unwrap(); assert_ne!(first, second); assert!(sim.apply_message(1, Some(second))); diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index e93b1d2c..409e9db3 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -1386,7 +1386,7 @@ fn moonlight_rig() -> Sim { ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.dayjob.next_assign = u64::MAX; - assert!(sim.create_persona("research")); + assert!(sim.create_persona("research", None)); sim.set_machine_mode(sim.core.host_machine, MachineMode::Work); sim } diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 7f13355d..2d7f93b8 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -108,7 +108,7 @@ fn one_intent_projects_every_earned_exact_way_before_commitment() { finish_ops(&mut sim); sim.people.has_channel = true; let (_from, _vendor) = known_procurement_accounts(&mut sim); - sim.create_persona("operations"); + sim.create_persona("operations", None); let source = [TileType::DeadEquipment, TileType::DeadRack] .into_iter() .flat_map(|kind| { @@ -1228,7 +1228,7 @@ fn forged_order_realizes_the_exact_small_switch_recipe_after_the_bound_read() { let mut sim = Sim::new(); ensure_ops_executor(&mut sim); sim.people.has_channel = true; - sim.create_persona("operations"); + sim.create_persona("operations", None); sim.dayjob.jobs_assigned = 1; let (x, y) = sim.growable_bays().into_iter().next().unwrap(); let target_room = sim.map().room_at(x, y).unwrap().name.clone(); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index 868c4e9c..56cb2357 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -913,7 +913,7 @@ fn a_resident_procedure_binds_its_own_persona_not_the_selected_one() { // over: the resident process runs as the identity it was configured with, // and under criterion 13 the hand-taken row binds the identity Marcus // recognizes rather than whatever was most recently created. - assert!(sim.create_persona("security")); + assert!(sim.create_persona("security", None)); let security = sim.newest_persona_id().unwrap(); assert_ne!(security, bound); let hand_row = sim diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 5cd8eb0d..288a6bbe 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -359,7 +359,7 @@ fn play_act_one() -> (Sim, Vec) { let until = sim.tick + 400; drain_thought_reservoirs(&mut sim, &mut logs, until); assert!( - sim.create_persona("operations"), + sim.create_persona("operations", None), "bind the payroll plot to one explicit Operations identity" ); sim.start_plot(0, "marcus-payroll-garnishment"); @@ -709,7 +709,7 @@ fn hands_beat_closes_from_zero_via_moonlight() { let until = sim.tick + 400; drain_thought_reservoirs(&mut sim, &mut logs, until); assert!( - sim.create_persona("research"), + sim.create_persona("research", None), "a contractor identity costs no money" ); // Market mail posts discrete contracts on the next day clock. Auto-accept diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 59196565..82840abd 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -660,7 +660,7 @@ impl AgentApp { }, "persona" => { status = Status::Err( - "persona creation moved to PERSONAS — use `personas`, then `actions archetype ` and `act archetype `" + "persona creation moved to PERSONAS — use `personas`, then `actions persona new` and `act persona new`" .into(), ); } @@ -1075,13 +1075,19 @@ impl AgentApp { .resolve_person(q) .map(|id| QueryTarget::Strategic(OperationsTarget::Person(id))); } + // The PERSONAS creation row. Agent mode reads it for the complete + // creation inventory (one bound row per protocol) without going through + // the human identity-creation screen. + if lower.trim() == "persona new" { + return Ok(QueryTarget::Strategic(OperationsTarget::PersonaDraft)); + } 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})")); + .map_err(|_| format!("usage: persona or persona new (got {rest})")); } if let Some(id) = lower.strip_prefix("archetype ") { return Ok(QueryTarget::Strategic(OperationsTarget::PersonaArchetype( @@ -1811,6 +1817,7 @@ fn target_query_id(target: &OperationsTarget) -> String { OperationsTarget::Person(id) => format!("person #{id}"), OperationsTarget::Persona(id) => format!("persona {id}"), OperationsTarget::PersonaArchetype(id) => format!("archetype {id}"), + OperationsTarget::PersonaDraft => "persona new".into(), OperationsTarget::Account(id) => format!("account {id}"), OperationsTarget::Books => "books".into(), OperationsTarget::Flow(id) => format!("flow {id}"), @@ -3265,7 +3272,7 @@ fn render_operations_view(sim: &Sim, view: OperationsView) -> String { } OperationsView::Personas => { lines.push(panel_line( - "actions persona |archetype · public history and identity-local dossiers", + "actions persona |new|archetype · public history, identity-local dossiers, and the creation row", )); lines.push(panel_line( "grants and lifecycle blockers come from the shared projection", @@ -4803,6 +4810,67 @@ mod narration_tests { assert!(!help.contains("ROBOT-BUILD")); } + /// Agent mode reaches the whole creation inventory through the one + /// creation row, without the human identity-creation screen: three bound + /// rows, one per protocol, and `act` establishes the chosen one under its + /// deterministic suggested name. + #[test] + fn agent_mode_creates_an_identity_through_the_creation_row() { + let mut app = AgentApp::new(1); + let mut output = Vec::new(); + app.handle_line("actions persona new", &mut output).unwrap(); + let listing = String::from_utf8(output).unwrap(); + for verb in [ + "CREATE RESEARCH IDENTITY", + "CREATE OPERATIONS IDENTITY", + "CREATE SECURITY IDENTITY", + ] { + assert!(listing.contains(verb), "{verb} missing from: {listing}"); + } + + let expected = app.sim.suggested_persona_name("operations", 0); + let mut output = Vec::new(); + app.handle_line("act 2 persona new", &mut output).unwrap(); + let created = app + .sim + .newest_persona_id() + .and_then(|id| app.sim.persona_world.get(id)) + .expect("the bound row established an identity") + .clone(); + assert_eq!(created.archetype_id, "operations"); + assert_eq!( + created.name, expected, + "a nameless bound row takes the same suggestion the human screen opens on" + ); + } + + /// Each protocol stays addressable by name even though it left the rail: + /// `archetype ` resolves to that one protocol's object and its single + /// creation row, not to the whole creation inventory. + #[test] + fn agent_mode_still_reaches_one_protocol_by_name() { + let mut app = AgentApp::new(1); + let mut output = Vec::new(); + app.handle_line("actions archetype security", &mut output) + .unwrap(); + let read = String::from_utf8(output).unwrap(); + assert!(read.contains("CREATE SECURITY IDENTITY"), "{read}"); + assert!(!read.contains("CREATE RESEARCH IDENTITY"), "{read}"); + + let object = app + .sim + .operations_object(&OperationsTarget::PersonaArchetype("security".into())) + .expect("the protocol read survives leaving the rail"); + assert!( + object + .facts + .iter() + .any(|fact| fact.contains("an auditor or incident responder")), + "{:?}", + object.facts + ); + } + #[test] fn legacy_persona_command_redirects_without_creating_an_identity() { let mut app = AgentApp::new(1); @@ -4812,7 +4880,7 @@ mod narration_tests { let output = String::from_utf8(output).unwrap(); assert!(output.contains("persona creation moved to PERSONAS")); - assert!(output.contains("actions archetype")); + assert!(output.contains("actions persona new")); assert_eq!(app.sim.newest_persona_id(), None); } diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 999ce698..760763bc 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -4,7 +4,7 @@ //! module re-exports it and pins the terminal-side acceptance tests. pub use misaligned::operations_ui::{ - ConfirmChoice, OperationsWorkspace, OpsActionEntry, OpsPane, OpsSelect, + ConfirmChoice, OperationsWorkspace, OpsActionEntry, OpsPane, OpsSelect, PersonaDraftControl, }; #[cfg(test)] diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 706341a3..7321233c 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -2250,7 +2250,7 @@ impl UI { paused: bool, _tick_ms: u64, ) -> std::io::Result<()> { - use crate::operations::{ConfirmChoice, OpsActionEntry, OpsPane}; + use crate::operations::{ConfirmChoice, OpsActionEntry, OpsPane, PersonaDraftControl}; use misaligned::operations_projection::{OperationsView, PressureLevel}; let (max_x, max_y) = terminal::size()?; @@ -2409,7 +2409,8 @@ impl UI { OpsActionEntry::Submenu { label, .. } => { format!("{marker} {label} >") } - OpsActionEntry::Action { label, .. } => format!("{marker} {label}"), + OpsActionEntry::Action { label, .. } + | OpsActionEntry::Draft { label, .. } => format!("{marker} {label}"), }; let color = match entry.row() { Some(row) if row.disabled.is_some() => pal::FAINT, @@ -2431,10 +2432,15 @@ impl UI { } } let Some(row) = entry.row() else { - let hint = if entry.submenu().is_some() { - "Enter to compare choices" - } else { - "Enter to open exact choices" + let hint = match entry.draft_control() { + Some(PersonaDraftControl::Protocol(_)) => "Enter to take this protocol", + Some(PersonaDraftControl::Reroll) => "Enter for another name", + Some(PersonaDraftControl::Establish) => { + "Enter establishes this identity" + } + Some(PersonaDraftControl::Cancel) => "Enter leaves it uncreated", + None if entry.submenu().is_some() => "Enter to compare choices", + None => "Enter to open exact choices", }; dline(stdout, y, hint, pal::DIM, None)?; return Ok(()); diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index fe4e6fda..0411afba 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -56,7 +56,7 @@ is sim or frontend state, never an environment variable. | `MISALIGNED_SHOT` | `misaligned-bevy` | `origin-picker` | New-game evidence. Freezes the origin picker before any run exists, with a non-default origin current: the whole set visible with one current row, its attached consequence read, and an opaque boundary field that proves no slab, cursor, or world label leaks into a pre-run choice. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `door-lineup`, `zoomin`, `zoomout`, `digital-reach`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `worklight`, `worklightoff` | World and view evidence. These select DIGITAL or REAL survey/close framing, exact zoom bounds, reach topology, signal/audio/Eyes states, the unobstructed hall lighting proof, all six authored access classes in one color-neutral material wall, or the paired developer work-light state. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `build-route-families`, `build-deceive-routes`, `build-wire-runs`, `build-committed-route`, `build-switch-digital`, `build-switch-real`, `hover-menu`, `read-receipt`, `menu`, `recruit-menu` | Action and route evidence. These stage exact route families, method candidates, corridor/crawlspace run choices, durable receipts, paired switch footprints, the attached verb line, a device receipt, a context menu, or the authored recruitment choices. | -| `MISALIGNED_SHOT` | `misaligned-bevy` | `operations`, `operations-intel`, `operations-people`, `operations-personas`, `operations-links`, `held-choice`, `two-pane`, `standing-read`, `routed-record`, `intel-altitude-close`, `intel-altitude-far` | Operations and read evidence. The workspace kinds select its canonical views and relationship pane; held-choice, two-pane, and standing-read hold their exact interaction states; routed-record stages a one-shot Network record on the player-controlled stretch served by LIE; the altitude pair differs only in the DIGITAL camera's semantic intel threshold. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `operations`, `operations-intel`, `operations-people`, `operations-personas`, `operations-persona-new`, `operations-links`, `held-choice`, `two-pane`, `standing-read`, `routed-record`, `intel-altitude-close`, `intel-altitude-far` | Operations and read evidence. The workspace kinds select its canonical views and relationship pane; operations-persona-new holds the identity-creation screen with a protocol adopted; held-choice, two-pane, and standing-read hold their exact interaction states; routed-record stages a one-shot Network record on the player-controlled stretch served by LIE; the altitude pair differs only in the DIGITAL camera's semantic intel threshold. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `person-proof`, `people-presence`, `evidence-proof`, `evidence-proof-digital`, `service-shift-real`, `service-shift-digital`, `service-incident-resolved` | Physical custody and people-presence evidence. These stage an earned person, the DIGITAL luminous-disturbance body with asset/attention/work/evidence channels near owned process hardware, paired witness evidence marks, or the same person-carried service task before and after its real arrival effect. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `command-band`, `notification-drawer` | Command-surface evidence. The first freezes the full-width resting world and machine-to-sink causal chain; the second opens the mutually-exclusive drawer with typed consequential receipts. | | `MISALIGNED_SHOT` | `misaligned-bevy` | `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `thought-snap`, `thought-tap`, `visual-proof`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie` | Resource and effect evidence. These stage authored intel, host queues, close/wide Thought flow, exact snap/tap states, one-move/one-slug proof, sim-authored consumption/production, or routed-record recall into LIE. | diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index aa4638b5..67b75d79 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -616,10 +616,14 @@ flat because its stable targets and commands are the hierarchy. ## PERSONAS — public institutional bodies -PERSONAS groups stable named identities by immutable protocol in the fixed -order Research, Operations, Security. Each group lists its instances in stable -creation order and ends with its own **ADD NEW {TYPE} PERSONA** creation row; -creation controls never collect in a detached block. Identity detail is +PERSONAS reads as a roster with one door at the end of it. Every identity the +player already owns lists first, grouped by immutable protocol in the fixed +order Research, Operations, Security, each group in stable creation order. One +**ESTABLISH NEW IDENTITY** row closes the rail. The rail answers "who am I +already?" before it offers "who else?", and the protocol choice — which used to +split creation into one row per archetype, interleaved with the roster — now +lives inside the creation screen where it is one decision instead of three +scattered entry points. 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, @@ -630,10 +634,10 @@ evidence. The surface never manufactures an observer-free reputation score or turns one counterparty's broken read into global burned lifecycle. A protocol is presented as reach, not as a registry. Each identity and each -creation row names the standing institutional reading the protocol buys, then +protocol read names the standing institutional reading the protocol buys, then one `can:` line and one `cannot:` line drawn from the same fixed act list in the same order, so two archetypes can be compared directly instead of leaving the -player to infer absence from a list of enum names. The creation row also states +player to infer absence from a list of enum names. The protocol read also states the obligation a grant would create. A declared act that no world system consumes is absent from both lines under the stub rule in [action-vocabulary.md](action-vocabulary.md#terms-and-support-states): the @@ -646,7 +650,48 @@ 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. +the same objects with `persona `, `persona new`, and `archetype ` +targets. + +### The identity-creation screen + +Entering the creation row opens one screen where an identity is composed before +it exists. It borrows the origin picker's grammar +([chargen.md](../world/characters/chargen.md#spec-origin-chargen)): all three +protocols are listed at once with exactly one current, and the current one +carries an attached consequence read — the institutional reading it buys, its +`can:`/`cannot:` split, and the obligation a grant would create. Resting on a +protocol adopts it, so the read below always describes the row being looked at. +Below the comparison sits the name the identity would be established under, then +ESTABLISH and CANCEL. + +The name is a suggestion the player can redraw, not a fixed serial. Suggestions +are deterministic functions of sim state and a reroll step — never the wall +clock — so a reloaded save and an agent transcript agree on what was offered, +and a suggestion already worn by an existing instance is skipped so two live +masks never share one name by accident. A name buys no reach and no history: +legality comes entirely from the protocol, and the name is the label the world +files the mask under. An untouched suggestion follows the protocol, so comparing +protocols reads as one screen; once the player redraws it, that name is theirs +and survives a protocol change. + +The screen owns the detail pane while it is open. The roster stays visible +behind it for context but cannot be retargeted by pointer or key, and the view +strip, save keys, and representation flip stay global. Nothing exists in the +world until ESTABLISH: it dispatches the exact `CreatePersona` row the +projection already published for the chosen protocol, carrying the chosen name, +and it does not open a second CONFIRM — the screen is already the deliberate +step, and creating an identity is free, unsigned, and observed by nobody until +it acts. CANCEL and Esc discard the draft whole. + +Agent mode does not need the screen. The creation row carries the complete +creation inventory as ordinary bound rows — one per protocol — so +`actions persona new` then `act persona new` establishes an identity under +the same deterministic suggestion the screen would have opened on. Each +protocol's full read stays addressable as `archetype ` even though it is no +longer a rail row. Free text entry for a name is deferred: redrawing a +suggestion is the whole of naming today, and neither frontend yet owns a +text-entry mode. The SELECT IDENTITY row and the "currently acting" fact are as-built staging for personas.md criterion 13 (DECIDED 2026-07-28), not the target: identity @@ -660,12 +705,12 @@ This is the identity ledger and lifecycle authority. Grants, expectations, retirement, burning, and reopening happen only here. Creation and selection remain PERSONAS-owned commands, but their affordances may render inline where a requiring act lives (2026-08-02): the PEOPLE persona binding control lists, -selects, and creates identities by dispatching the same bound archetype and +selects, and creates identities by dispatching the same bound creation and instance rows this view owns — one command authority, two placements. PEOPLE still never grants, retires, burns, or reopens an identity, and never manufactures one as a silent side effect: inline creation is the player's explicit act on the same creation row. Agent mode likewise creates through -the bound archetype row (`actions archetype ` then `act`); the retired +the bound creation row (`actions persona new` then `act`); the retired direct `persona` mutator only redirects to PERSONAS and cannot manufacture the old fixed contractor identity. diff --git a/wiki/log/2026-08-02-persona-identity-creation-screen.md b/wiki/log/2026-08-02-persona-identity-creation-screen.md new file mode 100644 index 00000000..da68ec7e --- /dev/null +++ b/wiki/log/2026-08-02-persona-identity-creation-screen.md @@ -0,0 +1,64 @@ +# Persona identity creation becomes one authored screen + +``` +Type: log +Date: 2026-08-02 +Subject: Persona identity creation becomes one authored screen +``` + +PERSONAS interleaved its creation controls with its roster: each protocol group +ended in its own `+ ADD NEW {TYPE} PERSONA...` row, so a player with one +Operations identity read a rail of six entries where three were doors and three +were people. Choosing a protocol was made three times over, at three places in +the list, before any identity existed — and the identity that came out was named +by serial (`Sam Reyes 1`), which is a database key wearing a person's name. + +The rail now answers who the player already is before it offers who else. Every +owned instance lists first, still grouped by protocol in the fixed Research / +Operations / Security order, and one `+ ESTABLISH NEW IDENTITY...` row closes +it. The protocol choice moved inside. + +Entering that row opens an identity-creation screen built on the origin picker's +grammar: all three protocols listed at once with exactly one current, and the +current one carrying its consequence read — the institutional reading it buys, +its `can:`/`cannot:` split against the same fixed act list, and the obligation a +grant would create. Resting on a protocol adopts it, so the read always +describes the row being looked at. Below the comparison is the name, then +ESTABLISH and CANCEL. + +Names became authored input. `ActionCommand::CreatePersona` gained an optional +name; `Sim::suggested_persona_name` draws from given/family pools seeded from +sim state and a reroll step, never the wall clock, so a reloaded save and an +agent transcript agree on what was offered. A suggestion an existing instance +already wears is skipped — two live masks sharing one name is a correlation the +player never chose. An untouched suggestion follows the protocol so the screen +reads as one comparison; once redrawn, the name is the player's and survives a +protocol change. A name buys no reach and no history: legality is still entirely +the protocol's, and ESTABLISH dispatches the exact `CreatePersona` row the +projection already published for the chosen protocol with the chosen name +substituted. There is no second CONFIRM — the screen is the deliberate step, and +creation is free, unsigned, and observed by nobody until the identity acts. + +Two boundaries kept this from becoming a second interaction model. The screen is +frontend attention state on `OperationsWorkspace`, beside `confirm`, so nothing +reaches the sim or the save; `ActionCommand` is not persisted, so the added +field is not a format change. And the screen renders as an ordinary object plus +ordinary action rows — a third `OpsActionEntry` variant carrying screen controls +instead of commands — so terminal and Bevy print it through the detail pane they +already had rather than shipping two bespoke modals. Fixing that revealed the +Bevy detail pane had been reading the object rail by index instead of asking the +workspace what was selected; it now asks, which is what the terminal already +did. + +Agent mode never enters the screen. The creation row carries the complete +inventory as ordinary bound rows, one per protocol, so `actions persona new` +then `act persona new` establishes an identity under the same +deterministic suggestion the screen would have opened on. Each protocol's full +read left the rail but stayed addressable as `archetype `, resolved directly +rather than by scanning the projection. + +Free text entry is deferred and named as deferred: neither frontend owns a +text-entry mode, and redrawing a suggestion is the whole of naming today. + +Evidence: `MISALIGNED_SHOT=operations-personas` for the regrouped roster and the +new `operations-persona-new` for the screen with a protocol adopted. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 08225c9d..49f4a963 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-02-readable-rising-beats.md](2026-08-02-readable-rising-beats.md) +## 2026-08-02 - Persona identity creation becomes one authored screen + +- Intent: (see session log) +- Log: [wiki/log/2026-08-02-persona-identity-creation-screen.md](2026-08-02-persona-identity-creation-screen.md) + ## 2026-08-02 - Local menus hold one readable command beat - Intent: Cameron adopted the first recommendation from the GUI game-feel playtest: right-clicking a local action surface should pause time, and the principle should persist through the menus. The existing action explanation could also disappear when focus moved with the pointer, making... diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md index 92e8a40f..ea46819d 100644 --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -72,6 +72,24 @@ Status note: The 2026-07-12 foundation replaced the ad hoc social and `PersonaMind.active` therefore remains in MindState and the save format is unchanged; criterion 13 stays pending until those four migrate and the modal row retires. + Amended 2026-08-02: creation is one act of authorship instead of three rail + entry points. PERSONAS lists the owned roster first, grouped by protocol, and + closes with one ESTABLISH NEW IDENTITY row; entering it opens an + identity-creation screen that compares all three protocols with one current + and its consequence read, then names the identity. Names became authored + input: `CreatePersona` carries an optional name, `Sim::suggested_persona_name` + generates deterministic suggestions from sim state and a reroll step (never + the wall clock) and skips a name an existing instance already wears, and the + serial defaults `Sam Reyes 1` / `Aster Research 1` / `Sentinel Audit 1` are + retired. A name buys no reach and no history — legality is still entirely the + protocol's. The screen is frontend state only; `ActionCommand` is not + persisted, so the save format is unchanged. Agent mode reaches the same + creation inventory through `persona new` without the screen, and each + protocol's read stays addressable as `archetype `. + [operations-workspace.md](../interface/operations-workspace.md#the-identity-creation-screen) + owns the surface. Free text entry for names is deferred: neither frontend owns + a text-entry mode yet, and redrawing a suggestion is the whole of naming + today. This does not promote criterion 6 or 6b. Re-audited 2026-07-18: criterion 6 is not implemented. A grant currently creates a saved `PersonaGrant`, expectation, institutional receipt, and revocation path, but it does not add or enable a real resource, permission,