From 0e1fc1bde5b3a5a5a58479f5d456daed5d75655f Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 3 Aug 2026 09:32:36 -0700 Subject: [PATCH] Compose new identities on one authored screen. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PERSONAS interleaved creation with its roster: every protocol group ended in its own ADD NEW {TYPE} PERSONA row, so the protocol choice was made three times over at three places in the list, and the identity that came out was named by serial. The rail now lists every owned identity first, still grouped by protocol in the fixed Research / Operations / Security order, and closes with one ESTABLISH NEW IDENTITY row. Entering that row opens an identity-creation screen on the origin picker's grammar: all three protocols at once with exactly one current, the current one carrying its consequence read, then the name, ESTABLISH, and CANCEL. Names became authored input — CreatePersona carries an optional name and Sim::suggested_persona_name draws deterministic suggestions from sim state and a reroll step, never the wall clock, skipping a name an existing instance already wears. The screen is frontend attention state beside `confirm`, and it renders as an ordinary object plus ordinary action rows, so terminal and Bevy print it through the detail pane they already had. Agent mode skips it entirely: the creation row carries one bound row per protocol under `persona new`. Fixing the draft's detail pane exposed that Bevy read the object rail by index instead of asking the workspace what was selected; it now asks, as the terminal already did. Defense: implements operations-workspace.md's amended PERSONAS section and its new "identity-creation screen" clause — the roster reads before the door, the protocol comparison is one decision carrying its consequence read, ESTABLISH dispatches the exact projected CreatePersona row rather than a second execution model, and confirmation still follows consequence so a free unsigned creation does not ask twice. Amends personas.md for authored names and the retired serial defaults. ActionCommand is not persisted, so the save format is unchanged. --- crates/misaligned-bevy/src/main.rs | 1 + crates/misaligned-bevy/src/operations_ui.rs | 27 +- crates/misaligned-bevy/src/shot_harness.rs | 34 +- crates/misaligned-core/src/actions.rs | 11 +- .../src/operations_projection.rs | 357 +++++++---- crates/misaligned-core/src/operations_ui.rs | 577 +++++++++++++++++- crates/misaligned-core/src/persona.rs | 64 ++ crates/misaligned-core/src/save.rs | 2 +- crates/misaligned-core/src/sim/social_plot.rs | 56 +- .../src/sim/tests/communications.rs | 2 +- .../misaligned-core/src/sim/tests/economy.rs | 2 +- .../src/sim/tests/reach_build.rs | 4 +- .../src/sim/tests/social_plot.rs | 2 +- crates/misaligned-core/tests/act_one.rs | 4 +- crates/misaligned-terminal/src/agent.rs | 76 ++- crates/misaligned-terminal/src/operations.rs | 2 +- crates/misaligned-terminal/src/ui.rs | 18 +- wiki/engineering/env.md | 2 +- wiki/interface/operations-workspace.md | 63 +- ...-08-02-persona-identity-creation-screen.md | 64 ++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/personas.md | 18 + 22 files changed, 1198 insertions(+), 193 deletions(-) create mode 100644 wiki/log/2026-08-02-persona-identity-creation-screen.md 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, -- 2.51.2