From fb9c42c26675862c054d8d8cf6adb9d64ec6d68e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 24 Aug 2026 07:33:48 +0200 Subject: [PATCH] Bind direct speech to exact Persona responses --- .../src/bin/ghostlight-live-turn-smoke.rs | 1 + crates/ghostlight-dungeon/src/compiler.rs | 1 + crates/ghostlight-dungeon/src/domain.rs | 15 + crates/ghostlight-dungeon/src/gestalt.rs | 2 + crates/ghostlight-dungeon/src/kernel.rs | 201 ++++++++++- crates/ghostlight-dungeon/src/main.rs | 81 ++++- crates/ghostlight-dungeon/src/model.rs | 4 +- crates/ghostlight-dungeon/src/persona.rs | 95 +++++- crates/ghostlight-dungeon/src/surface.rs | 3 + crates/ghostlight-dungeon/src/turn.rs | 316 +++++++++++++++++- docs/architecture/ghostlight-dungeon-mvp.md | 13 + notes/ghostlight-current-system-map.md | 16 + notes/ghostlight-implementation-plan.md | 9 + 13 files changed, 732 insertions(+), 25 deletions(-) diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs index d9cc71f..f3e2a2f 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs @@ -41,6 +41,7 @@ async fn main() -> anyhow::Result<()> { at: campaign.world_time, speaker: "world".into(), text: event_summary.clone(), + persona_response_actor_ids: Default::default(), }); let store = CampaignStore::open(root.join("campaign.cc"))?; store.create_campaign(&campaign, &[], &[])?; diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 152b57b..c7ec1fe 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -2918,6 +2918,7 @@ fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Re at: now, speaker: "world".into(), text: seed.opening_narration, + persona_response_actor_ids: BTreeSet::new(), }], last_player_activity: now, pending_ticks: 0, diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 8ba5322..c17f7cc 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -646,6 +646,11 @@ pub struct WorldActionProposal { pub struct ActorReaction { pub actor_id: String, pub speech: Option, + /// A typed, actor-owned refusal to answer. The kernel lowers this to a + /// deterministic visible transcript turn, so an Interpreter cannot smuggle + /// an unassessed physical effect through free-form reaction prose. + #[serde(default)] + pub deliberate_silence: bool, pub private_delta: ActorStateDelta, pub action_proposals: Vec, } @@ -846,6 +851,11 @@ pub struct NarrativeTurn { pub at: DateTime, pub speaker: String, pub text: String, + /// Exact model-controlled actors whom this committed speech asks to + /// respond. Other present actors still perceive and appraise the turn, but + /// they are not response-bound. Human-controlled actors never appear here. + #[serde(default)] + pub persona_response_actor_ids: BTreeSet, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -890,6 +900,11 @@ pub enum WorldCommand { actor_id: String, text: String, intended_effect: Option, + /// Filled by the server-side scene-address resolver from the exact + /// present-actor catalog. Player boundaries must not accept authority + /// IDs in this field. + #[serde(default)] + persona_response_actor_ids: BTreeSet, }, Assess { expected_revision: u64, diff --git a/crates/ghostlight-dungeon/src/gestalt.rs b/crates/ghostlight-dungeon/src/gestalt.rs index 35e18b2..418e713 100644 --- a/crates/ghostlight-dungeon/src/gestalt.rs +++ b/crates/ghostlight-dungeon/src/gestalt.rs @@ -481,6 +481,7 @@ mod tests { at: chrono::Utc::now(), speaker: campaign.player_actor_id.clone(), text: "I ask the unnamed porter for their name.".into(), + persona_response_actor_ids: BTreeSet::new(), }); let exact = format!( "{} says: I ask the unnamed porter for their name.", @@ -496,6 +497,7 @@ mod tests { at: chrono::Utc::now(), speaker: "world".into(), text: "They agree to help.".into(), + persona_response_actor_ids: BTreeSet::new(), }); campaign.revision = 8; assert!(automatic_individuation_stimulus(&campaign).is_none()); diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index b68b13f..21ce569 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -348,6 +348,7 @@ fn execute( at: Utc::now(), speaker: "world".into(), text: text.clone(), + persona_response_actor_ids: BTreeSet::new(), }); commit_mutation_transition( store, @@ -364,20 +365,40 @@ fn execute( actor_id, text, intended_effect, + persona_response_actor_ids, } => { require_revision(&campaign, expected_revision)?; validate_bounded_text("speech", &text, 4_000)?; if let Some(effect) = &intended_effect { validate_bounded_text("intended effect", effect, 1_000)?; } - if !campaign.actors.contains_key(&actor_id) { - return Err(KernelError::Invalid("unknown actor".into())); + let speaker = campaign + .actors + .get(&actor_id) + .ok_or_else(|| KernelError::Invalid("unknown actor".into()))?; + for response_actor_id in &persona_response_actor_ids { + let response_actor = campaign.actors.get(response_actor_id).ok_or_else(|| { + KernelError::Invalid("speech response actor is unknown".into()) + })?; + if response_actor_id == &actor_id + || response_actor.location_id != speaker.location_id + || campaign + .agency_profiles + .get(response_actor_id) + .is_some_and(|profile| !profile.simulation_eligible) + { + return Err(KernelError::Invalid( + "speech response actor is not an eligible present Persona".into(), + )); + } } + let response_actor_ids = persona_response_actor_ids.clone(); campaign.transcript.push(NarrativeTurn { revision: campaign.revision + 1, at: Utc::now(), speaker: actor_id.clone(), text, + persona_response_actor_ids, }); if let Some(effect) = intended_effect { campaign.transcript.push(NarrativeTurn { @@ -385,11 +406,13 @@ fn execute( at: Utc::now(), speaker: "system".into(), text: format!("Intended effect requires assessment: {effect}"), + persona_response_actor_ids: BTreeSet::new(), }); } refresh_materialized_member_relevance( &mut campaign, - std::iter::once(actor_id.as_str()), + std::iter::once(actor_id.as_str()) + .chain(response_actor_ids.iter().map(String::as_str)), ); if is_human_controlled_actor(&campaign, &actor_id) { campaign.last_player_activity = Utc::now(); @@ -1265,7 +1288,9 @@ fn execute( reactions, } => { require_revision(&campaign, expected_revision)?; - let witnessed_memory = canonical_witnessed_memory(&campaign, &event_summary)?; + let witnessed_turn = canonical_witnessed_turn(&campaign, &event_summary)?; + let response_expected_actor_ids = witnessed_turn.persona_response_actor_ids.clone(); + let witnessed_memory = format!("Witnessed: {}", canonical_turn_text(witnessed_turn)); let player_location = campaign.actors[&campaign.player_actor_id] .location_id .clone(); @@ -1288,10 +1313,33 @@ fn execute( "reaction Interpreter cannot write actor memory".into(), )); } + if let Some(speech) = &reaction.speech { + validate_bounded_text("reaction speech", speech, 1_000)?; + } for proposal in &reaction.action_proposals { validate_world_proposal(actor, proposal)?; } } + for response_actor_id in &response_expected_actor_ids { + let reaction = reactions + .iter() + .find(|reaction| &reaction.actor_id == response_actor_id) + .ok_or_else(|| { + KernelError::Invalid( + "directly addressed Persona is absent from the reaction wave".into(), + ) + })?; + if reaction + .speech + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && !reaction.deliberate_silence + { + return Err(KernelError::Invalid( + "directly addressed Persona produced no observable response".into(), + )); + } + } let source_receipt_id = format!( "reaction-input:{}", crate::legacy_transition::digest_serializable(&reactions) @@ -1319,6 +1367,16 @@ fn execute( at: Utc::now(), speaker: reaction.actor_id.clone(), text: speech, + persona_response_actor_ids: BTreeSet::new(), + }); + } + if reaction.deliberate_silence { + campaign.transcript.push(NarrativeTurn { + revision: campaign.revision + 1, + at: Utc::now(), + speaker: reaction.actor_id.clone(), + text: "deliberately does not answer.".into(), + persona_response_actor_ids: BTreeSet::new(), }); } campaign @@ -1450,6 +1508,7 @@ fn execute( at: Utc::now(), speaker: "world".into(), text: text.clone(), + persona_response_actor_ids: BTreeSet::new(), }); Some((roll, transition, mutation_receipt)) } else { @@ -1708,21 +1767,16 @@ fn validate_world_proposal( Ok(()) } -fn canonical_witnessed_memory( - campaign: &Campaign, +fn canonical_witnessed_turn<'a>( + campaign: &'a Campaign, event_summary: &str, -) -> Result { +) -> Result<&'a NarrativeTurn, KernelError> { let event_summary = event_summary.trim(); - let witnessed = campaign.transcript.iter().rev().find_map(|turn| { - let canonical = if turn.speaker == "world" { - turn.text.trim().to_owned() - } else { - format!("{} says: {}", turn.speaker, turn.text.trim()) - }; - (canonical == event_summary).then_some(canonical) - }); - witnessed - .map(|canonical| format!("Witnessed: {canonical}")) + campaign + .transcript + .iter() + .rev() + .find(|turn| canonical_turn_text(turn) == event_summary) .ok_or_else(|| { KernelError::Invalid( "reaction stimulus does not match a committed transcript turn".into(), @@ -1730,6 +1784,14 @@ fn canonical_witnessed_memory( }) } +fn canonical_turn_text(turn: &NarrativeTurn) -> String { + if turn.speaker == "world" { + turn.text.trim().to_owned() + } else { + format!("{} says: {}", turn.speaker, turn.text.trim()) + } +} + fn overlay( base: &BTreeSet, add: &BTreeSet, @@ -3383,6 +3445,7 @@ mod tests { at: Utc::now(), speaker: "world".into(), text: "They agree to help design the relay.".into(), + persona_response_actor_ids: BTreeSet::new(), }); seed.gestalts.insert( "refugees".into(), @@ -5020,6 +5083,7 @@ mod tests { actor_id: "player".into(), text: "x".repeat(4_001), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }) .await .is_err() @@ -5059,6 +5123,7 @@ mod tests { actor_id: "npc".into(), text: "The world does not wait.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }) .await .unwrap(); @@ -5224,6 +5289,7 @@ mod tests { actor_id: "player".into(), text: "Wait.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }) .await .unwrap(); @@ -5764,6 +5830,94 @@ mod tests { assert_eq!(receipts[2].resolution_epoch, 3); } + #[tokio::test] + async fn directly_addressed_persona_must_commit_an_observable_response() { + let dir = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); + let kernel = WorldKernel::start(store.clone()); + let mut seed = campaign(); + seed.actors.insert( + "anna".into(), + ActorState { + id: "anna".into(), + name: "Anna".into(), + location_id: "room".into(), + capabilities: BTreeSet::new(), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + }, + ); + seed.transcript.push(NarrativeTurn { + revision: 0, + at: seed.world_time, + speaker: "player".into(), + text: "Anna, answer me.".into(), + persona_response_actor_ids: BTreeSet::from(["anna".into()]), + }); + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed.clone(), + evidence_receipts: vec![], + model_stage_receipts: vec![], + }) + .await + .unwrap(); + + let silent = ActorReaction { + actor_id: "anna".into(), + speech: None, + deliberate_silence: false, + private_delta: ActorStateDelta::default(), + action_proposals: vec![], + }; + let error = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "player says: Anna, answer me.".into(), + reactions: vec![silent], + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("no observable response")); + assert_eq!( + store + .load::("campaign.v1", &seed.id.to_string()) + .unwrap() + .unwrap() + .1 + .revision, + 0 + ); + + let committed = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "player says: Anna, answer me.".into(), + reactions: vec![ActorReaction { + actor_id: "anna".into(), + speech: None, + deliberate_silence: true, + private_delta: ActorStateDelta::default(), + action_proposals: vec![], + }], + }) + .await + .unwrap(); + let CommandResult::Committed { campaign, .. } = committed else { + panic!() + }; + assert_eq!(campaign.revision, 1); + assert_eq!( + campaign.transcript.last().unwrap().text, + "deliberately does not answer." + ); + } + #[tokio::test] async fn invalid_reaction_rejects_the_entire_wave_without_mutation() { let dir = tempfile::tempdir().unwrap(); @@ -5800,6 +5954,7 @@ mod tests { ActorReaction { actor_id: "anna".into(), speech: Some("I saw that.".into()), + deliberate_silence: false, private_delta: ActorStateDelta { memories_add: vec!["saw the event".into()], ..Default::default() @@ -5809,6 +5964,7 @@ mod tests { ActorReaction { actor_id: "bert".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![WorldActionProposal { actor_id: "bert".into(), @@ -5851,6 +6007,7 @@ mod tests { at: seed.world_time, speaker: "player".into(), text: "Tell me which seal I repaired.".into(), + persona_response_actor_ids: BTreeSet::new(), }); seed.actors.insert( "anna".into(), @@ -5884,6 +6041,7 @@ mod tests { reactions: vec![ActorReaction { actor_id: "anna".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta { memories_add: vec!["I repaired the seal.".into()], ..Default::default() @@ -5915,6 +6073,7 @@ mod tests { at: seed.world_time, speaker: "player".into(), text: "Tell me which seal I repaired.".into(), + persona_response_actor_ids: BTreeSet::new(), }); seed.actors.insert( "anna".into(), @@ -5948,6 +6107,7 @@ mod tests { reactions: vec![ActorReaction { actor_id: "anna".into(), speech: Some("I did not witness it.".into()), + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![], }], @@ -5977,6 +6137,7 @@ mod tests { at: seed.world_time, speaker: "world".into(), text: "a disturbance".into(), + persona_response_actor_ids: BTreeSet::new(), }); seed.last_player_activity = Utc::now() - Duration::hours(2); for id in ["anna", "bert"] { @@ -6024,12 +6185,14 @@ mod tests { ActorReaction { actor_id: "anna".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![anna.clone()], }, ActorReaction { actor_id: "bert".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![bert.clone()], }, @@ -6138,6 +6301,7 @@ mod tests { at: seed.world_time, speaker: "world".into(), text: "a disturbance".into(), + persona_response_actor_ids: BTreeSet::new(), }); seed.actors.insert( "anna".into(), @@ -6178,6 +6342,7 @@ mod tests { reactions: vec![ActorReaction { actor_id: "anna".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![proposal.clone()], }], @@ -6190,6 +6355,7 @@ mod tests { actor_id: "player".into(), text: "I move on before Anna acts.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }) .await .unwrap(); @@ -6226,6 +6392,7 @@ mod tests { reactions: vec![ActorReaction { actor_id: "anna".into(), speech: None, + deliberate_silence: false, private_delta: ActorStateDelta::default(), action_proposals: vec![], }], diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 6d3a138..a56ab66 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -42,7 +42,7 @@ use ghostlight_dungeon::{ surface::{ campaign_interface_version, player_surface_for_actor, rebase_campaign_surface_revision, }, - turn::{SnapshotPermit, appraise_present}, + turn::{SnapshotPermit, appraise_present, resolve_speech_addresses}, vault::VoidBotMcpVault, }; use serde::Deserialize; @@ -1468,6 +1468,7 @@ async fn dispatch_eve_product_command( actor_id, text: required_string!("text"), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }, "world.assess" => WorldCommand::Assess { expected_revision: required_u64!("expected_revision"), @@ -4205,6 +4206,65 @@ async fn command( let body = player_safe_strategic_failure(&error); return (StatusCode::CONFLICT, Json(body)).into_response(); } + if let WorldCommand::Speak { + expected_revision, + actor_id, + text, + persona_response_actor_ids, + .. + } = &mut command + { + let campaign = match load_campaign(&runtime.store) { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorBody { + error: error.to_string(), + }), + ) + .into_response(); + } + }; + if campaign.revision != *expected_revision { + return ( + StatusCode::CONFLICT, + Json(ErrorBody { + error: format!( + "stale revision: expected {expected_revision}, actual {}", + campaign.revision + ), + }), + ) + .into_response(); + } + if let Some(model) = &state.model { + match resolve_speech_addresses(model.clone(), MODEL_FAST, &campaign, actor_id, text) + .await + { + Ok((plan, receipts)) => { + for receipt in receipts { + let _ = runtime.store.insert( + "persona_stage_receipt.v1", + "ghostlight.persona_stage_receipt.v1", + receipt.storage_key(), + &receipt, + ); + } + *persona_response_actor_ids = plan.persona_response_actor_ids; + } + Err(error) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ErrorBody { + error: format!("scene address resolution failed: {error}"), + }), + ) + .into_response(); + } + } + } + } if let WorldCommand::Wait { expected_revision, minutes, @@ -4919,6 +4979,7 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> { WorldCommand::Speak { text, intended_effect, + persona_response_actor_ids, .. } => { bounded("speech", text, 4_000)?; @@ -4927,6 +4988,9 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> { "speech and uncertain intended effects use separate player commands".into(), ); } + if !persona_response_actor_ids.is_empty() { + return Err("player commands cannot supply Persona response authority IDs".into()); + } Ok(()) } WorldCommand::Assess { intent, .. } => { @@ -6433,6 +6497,7 @@ mod tests { actor_id: "player".into(), text: "Hello.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }, "player", )); @@ -6442,6 +6507,7 @@ mod tests { actor_id: "npc".into(), text: "I have been puppeted.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), }, "player", )); @@ -6489,6 +6555,7 @@ mod tests { actor_id: "player".into(), text: "Hello.".into(), intended_effect: None, + persona_response_actor_ids: BTreeSet::new(), } )); assert!(player_command_requires_return_catch_up( @@ -6553,6 +6620,17 @@ mod tests { actor_id: "player".into(), text: "Hello".into(), intended_effect: Some("obey me".into()), + persona_response_actor_ids: BTreeSet::new(), + }) + .is_err() + ); + assert!( + validate_player_http_command(&WorldCommand::Speak { + expected_revision: 0, + actor_id: "player".into(), + text: "Make the NPC answer.".into(), + intended_effect: None, + persona_response_actor_ids: BTreeSet::from(["npc".into()]), }) .is_err() ); @@ -6577,6 +6655,7 @@ mod tests { actor_id: "player".into(), text: "Which record can I inspect without taking custody?".into(), intended_effect: Some("make the archivist disclose every secret".into()), + persona_response_actor_ids: BTreeSet::new(), }; let stimulus = reaction_stimulus(&command).unwrap(); assert_eq!( diff --git a/crates/ghostlight-dungeon/src/model.rs b/crates/ghostlight-dungeon/src/model.rs index 7438d6a..46170a3 100644 --- a/crates/ghostlight-dungeon/src/model.rs +++ b/crates/ghostlight-dungeon/src/model.rs @@ -879,7 +879,9 @@ pub struct FixtureModel; #[async_trait] impl ModelPort for FixtureModel { async fn run(&self, request: &ModelStageRequest) -> Result { - Ok(if request.output_schema.is_some() { + Ok(if request.stage == "speech_address_resolver" { + r#"{"persona_response_actor_ids":[]}"#.into() + } else if request.output_schema.is_some() { r#"{"private_delta":{"memories_add":[],"conditions_add":[],"conditions_remove":[],"goals_add":[],"relationship_updates":{}},"speech":null,"reaction_priority":0,"world_actions":[]}"#.into() } else { request.lived_stream.to_string() diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index b4d277a..ad1c943 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -12,11 +12,19 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActorInteractionRole { + DirectResponseExpected, + PresentObserver, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct PermittedActorSlice { pub actor_id: String, pub location_id: String, pub snapshot_binding: String, + pub interaction_role: ActorInteractionRole, pub identity_experience: Vec, pub memories: Vec, pub perceived_events: Vec, @@ -41,6 +49,10 @@ pub struct LivedNarrativeStream { pub struct PersonaProposalBundle { pub private_delta: crate::domain::ActorStateDelta, pub speech: Option, + /// Typed refusal lane for a directly addressed actor. WorldKernel lowers + /// it deterministically; this field cannot carry free-form effects. + #[serde(default)] + pub deliberate_silence: bool, pub reaction_priority: i16, pub world_actions: Vec, } @@ -362,7 +374,7 @@ impl PersonaProjectionEngine { lived_stream: build_persona_prompt(&PersonaPrompt { identity: &slice.actor_id, lived_stream: &lived.text, - domain_guidance: "Respond as a situated character. Answer direct questions at human conversational length. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.", + domain_guidance: "Respond as a situated character. When the lived stream says a response is expected from you, make your answer, refusal, or deliberate silence observable now. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.", word_budget: 160, }), output_schema: None, @@ -395,7 +407,7 @@ impl PersonaProjectionEngine { domain_guidance: &permission_guidance, }), output_schema: Some(schema), - source_receipt_ids: slice.source_receipt_ids, + source_receipt_ids: slice.source_receipt_ids.clone(), temperature: Some(0.0), max_output_tokens: Some(768), }, @@ -410,6 +422,7 @@ impl PersonaProjectionEngine { .clone() .ok_or_else(|| anyhow!("interpreter produced no typed proposal"))?, )?; + validate_actor_proposals(&slice, &proposals)?; Ok(PersonaTerminalBundle { lived_stream: lived, persona_output: persona.narrative, @@ -420,8 +433,16 @@ impl PersonaProjectionEngine { } fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String { + let response_guidance = match slice.interaction_role { + ActorInteractionRole::DirectResponseExpected => { + "This actor is an exact direct addressee. Extract an explicit spoken response, or set deliberate_silence true only when the Persona deliberately refuses or remains silent. Do not erase the Persona's response into null fields." + } + ActorInteractionRole::PresentObserver => { + "This actor is present but was not directly asked to respond. Speech or observable action remains optional and must follow only from this actor's projected choice." + } + }; format!( - "Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.", + "{response_guidance} Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.", slice.actor_id, allowed_actor_references(slice), slice.perceived_actors.keys().collect::>() @@ -455,11 +476,46 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S } else { slice.memories[recent_memory_start..].join("; ") }; + let interaction = match slice.interaction_role { + ActorInteractionRole::DirectResponseExpected => { + "You are an exact direct addressee of the current speech. A response is expected from you now. You retain the agency to answer, refuse, or remain silent, but refusal or silence must be made observable rather than disappearing from the scene." + } + ActorInteractionRole::PresentObserver => { + "You are present and perceive the current event, but you were not directly asked to respond. You may react from your own goals and pressures; you need not seize conversational focus." + } + }; format!( - "{projection}\n\nYour reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact." + "{projection}\n\n{interaction} Your reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact." ) } +fn validate_actor_proposals( + slice: &PermittedActorSlice, + proposals: &PersonaProposalBundle, +) -> Result<()> { + if matches!( + slice.interaction_role, + ActorInteractionRole::DirectResponseExpected + ) && proposals + .speech + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && !proposals.deliberate_silence + { + return Err(anyhow!( + "directly addressed Persona produced no observable response" + )); + } + if proposals + .speech + .as_deref() + .is_some_and(|value| value.trim().is_empty() || value.chars().count() > 1_000) + { + return Err(anyhow!("Persona speech must contain 1 to 1000 characters")); + } + Ok(()) +} + fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value { serde_json::json!({ "cell_id": slice.cell_id, @@ -2537,6 +2593,36 @@ fn constrain_interpreter_schema( .and_then(|value| value.as_object_mut()) .ok_or_else(|| anyhow!("Persona proposal schema has no memory additions"))?; memories_add.insert("maxItems".into(), serde_json::json!(0)); + let root = schema + .as_object_mut() + .ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?; + let properties = root + .get_mut("properties") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| anyhow!("Persona proposal schema has no root properties"))?; + properties.insert( + "speech".into(), + serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":1000}), + ); + properties.insert( + "deliberate_silence".into(), + serde_json::json!({"type":"boolean"}), + ); + if matches!( + slice.interaction_role, + ActorInteractionRole::DirectResponseExpected + ) { + root.entry("allOf") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or_else(|| anyhow!("Persona proposal schema allOf is not an array"))? + .push(serde_json::json!({ + "anyOf":[ + {"required":["speech"],"properties":{"speech":{"type":"string","minLength":1,"maxLength":1000}}}, + {"required":["deliberate_silence"],"properties":{"deliberate_silence":{"const":true}}} + ] + })); + } Ok(()) } @@ -3834,6 +3920,7 @@ mod tests { actor_id: "npc".into(), location_id: "room".into(), snapshot_binding: "campaign:1".into(), + interaction_role: ActorInteractionRole::PresentObserver, identity_experience: vec!["A tired navigator".into()], memories: vec!["Proposed the eastern trail evacuation at dusk.".into()], perceived_events: vec![], diff --git a/crates/ghostlight-dungeon/src/surface.rs b/crates/ghostlight-dungeon/src/surface.rs index a8c4ead..157b251 100644 --- a/crates/ghostlight-dungeon/src/surface.rs +++ b/crates/ghostlight-dungeon/src/surface.rs @@ -475,18 +475,21 @@ mod tests { at: Utc::now(), speaker: "player".into(), text: "I ask the question.".into(), + persona_response_actor_ids: Default::default(), }, NarrativeTurn { revision: 2, at: Utc::now(), speaker: "world".into(), text: "raw outcome".into(), + persona_response_actor_ids: Default::default(), }, NarrativeTurn { revision: 3, at: Utc::now(), speaker: "npc".into(), text: "I answer directly.".into(), + persona_response_actor_ids: Default::default(), }, ]; let story = story_nodes(&campaign); diff --git a/crates/ghostlight-dungeon/src/turn.rs b/crates/ghostlight-dungeon/src/turn.rs index c64add5..4852446 100644 --- a/crates/ghostlight-dungeon/src/turn.rs +++ b/crates/ghostlight-dungeon/src/turn.rs @@ -1,11 +1,128 @@ use crate::{ domain::{ActorReaction, Campaign}, - model::ModelStageReceipt, + model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage}, persistence::CampaignStore, - persona::{ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine}, + persona::{ + ActorInteractionRole, ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine, + }, }; use anyhow::{Result, anyhow}; use async_trait::async_trait; +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, sync::Arc}; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct SpeechAddressPlan { + /// Exact present, model-controlled actors from whom the utterance requests + /// an answer. Mentioned people and passive hearers are not included. + pub persona_response_actor_ids: BTreeSet, +} + +/// Resolve conversational address from player language against the exact +/// present-actor catalog. The output is proposal-only until WorldKernel +/// validates co-presence and commits it with the speech turn. +pub async fn resolve_speech_addresses( + model: Arc, + model_name: &str, + campaign: &Campaign, + speaker_actor_id: &str, + speech: &str, +) -> Result<(SpeechAddressPlan, Vec)> { + let speaker = campaign + .actors + .get(speaker_actor_id) + .ok_or_else(|| anyhow!("speech actor is unknown"))?; + let candidates = campaign + .actors + .values() + .filter(|actor| { + actor.id != speaker_actor_id + && actor.location_id == speaker.location_id + && campaign + .agency_profiles + .get(&actor.id) + .is_none_or(|profile| profile.simulation_eligible) + }) + .map(|actor| (actor.id.clone(), actor.name.clone())) + .collect::>(); + if candidates.is_empty() { + return Ok(( + SpeechAddressPlan { + persona_response_actor_ids: BTreeSet::new(), + }, + Vec::new(), + )); + } + let allowed_ids = candidates.keys().cloned().collect::>(); + let previous_focus = campaign + .transcript + .iter() + .rev() + .find(|turn| { + turn.speaker == speaker_actor_id && !turn.persona_response_actor_ids.is_empty() + }) + .map(|turn| turn.persona_response_actor_ids.clone()) + .unwrap_or_default(); + let recent_public_turns = campaign + .transcript + .iter() + .rev() + .take(8) + .collect::>() + .into_iter() + .rev() + .map(|turn| { + serde_json::json!({ + "speaker":turn.speaker, + "text":turn.text, + "persona_response_actor_ids":turn.persona_response_actor_ids, + }) + }) + .collect::>(); + let mut schema = serde_json::to_value(schema_for!(SpeechAddressPlan))?; + schema["properties"]["persona_response_actor_ids"] = serde_json::json!({ + "type":"array", + "uniqueItems":true, + "maxItems":allowed_ids.len(), + "items":{"type":"string","enum":allowed_ids}, + }); + let prompt = format!( + "You are Ghostlight's private scene-address resolver. Decide only which supplied present, model-controlled actors this exact speech directly asks to answer now. A person who is merely mentioned, discussed, visible, or able to overhear is not a response target. A room-wide statement need not demand an answer from everyone. Preserve ordinary conversational focus from previous_focus when pronouns or an unqualified follow-up clearly continue it. Select only exact supplied actor IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT ACTORS:\n{}\nPREVIOUS FOCUS:\n{}\nRECENT PUBLIC TURNS:\n{}\nSPEAKER ACTOR ID:\n{}\nEXACT SPEECH:\n{}", + serde_json::to_string(&schema)?, + serde_json::to_string(&candidates)?, + serde_json::to_string(&previous_focus)?, + serde_json::to_string(&recent_public_turns)?, + serde_json::to_string(speaker_actor_id)?, + serde_json::to_string(speech)?, + ); + let output = run_validated_stage( + model.as_ref(), + &ModelStageRequest { + stage: "speech_address_resolver".into(), + model: model_name.into(), + snapshot_binding: format!("campaign:{}:revision:{}", campaign.id, campaign.revision), + lived_stream: prompt, + output_schema: Some(schema), + source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(), + temperature: Some(0.0), + max_output_tokens: Some(256), + }, + ) + .await?; + let plan: SpeechAddressPlan = serde_json::from_value( + output + .structured + .clone() + .ok_or_else(|| anyhow!("speech address resolver produced no plan"))?, + )?; + if !plan.persona_response_actor_ids.is_subset(&allowed_ids) { + return Err(anyhow!( + "speech address resolver selected an unavailable actor" + )); + } + Ok((plan, vec![output.receipt])) +} pub struct SnapshotPermit { store: CampaignStore, @@ -65,6 +182,9 @@ pub async fn appraise_present( campaign: &Campaign, event_summary: &str, ) -> Result { + let response_expected_actor_ids = canonical_reaction_turn(campaign, event_summary)? + .persona_response_actor_ids + .clone(); let player = campaign .actors .get(&campaign.player_actor_id) @@ -98,6 +218,11 @@ pub async fn appraise_present( let receipts = campaign.branch_origin.evidence_receipt_ids.clone(); let perceived_actors = perceived_actors.clone(); let semaphore = semaphore.clone(); + let interaction_role = if response_expected_actor_ids.contains(&actor.id) { + ActorInteractionRole::DirectResponseExpected + } else { + ActorInteractionRole::PresentObserver + }; jobs.spawn(async move { let _provider_slot = semaphore .acquire_owned() @@ -107,6 +232,7 @@ pub async fn appraise_present( actor_id: actor.id.clone(), location_id: actor.location_id.clone(), snapshot_binding: snapshot, + interaction_role, identity_experience: vec![format!("You are {}.", actor.name)], memories: actor.memories, perceived_events: vec![event], @@ -140,6 +266,7 @@ pub async fn appraise_present( reactions.push(ActorReaction { actor_id, speech: terminal.proposals.speech, + deliberate_silence: terminal.proposals.deliberate_silence, private_delta: terminal.proposals.private_delta, action_proposals: terminal.proposals.world_actions, }); @@ -150,3 +277,188 @@ pub async fn appraise_present( receipts, }) } + +fn canonical_reaction_turn<'a>( + campaign: &'a Campaign, + event_summary: &str, +) -> Result<&'a crate::domain::NarrativeTurn> { + let event_summary = event_summary.trim(); + campaign + .transcript + .iter() + .rev() + .find(|turn| { + let canonical = if turn.speaker == "world" { + turn.text.trim().to_owned() + } else { + format!("{} says: {}", turn.speaker, turn.text.trim()) + }; + canonical == event_summary + }) + .ok_or_else(|| anyhow!("reaction stimulus does not match a committed transcript turn")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + domain::{ActorState, NarrativeTurn}, + model::ModelStageRequest, + persona::{AllowAllPermit, PersonaProjectionEngine}, + }; + use chrono::Utc; + use std::collections::{BTreeMap, BTreeSet}; + + struct AddressAwareModel; + + #[async_trait] + impl ModelPort for AddressAwareModel { + async fn run(&self, request: &ModelStageRequest) -> Result { + Ok(match request.stage.as_str() { + "speech_address_resolver" => { + assert!(request.lived_stream.contains("Refugee One")); + assert!(request.lived_stream.contains("Refugee Two")); + assert!(request.lived_stream.contains("Relay Observer")); + serde_json::json!({ + "persona_response_actor_ids":["refugee-one","refugee-two"] + }) + .to_string() + } + "projector" => "The question lands in the small circle.".into(), + "persona" => { + if request.lived_stream.contains("exact direct addressee") { + "I answer plainly: that memory is mine alone.".into() + } else { + "I listen without taking over their answer.".into() + } + } + "interpreter" => { + let direct = request.lived_stream.contains("direct_response_expected"); + serde_json::json!({ + "private_delta":{ + "memories_add":[], + "conditions_add":[], + "conditions_remove":[], + "goals_add":[], + "relationship_updates":{}, + }, + "speech":direct.then_some("that memory is mine alone"), + "deliberate_silence":false, + "reaction_priority":0, + "world_actions":[], + }) + .to_string() + } + stage => return Err(anyhow!("unexpected stage {stage}")), + }) + } + + fn provider(&self) -> &'static str { + "fixture" + } + } + + fn actor(id: &str, name: &str, location_id: &str) -> ActorState { + ActorState { + id: id.into(), + name: name.into(), + location_id: location_id.into(), + capabilities: BTreeSet::from(["ordinary conversation".into()]), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + } + } + + fn addressed_campaign() -> Campaign { + let mut campaign = crate::resolution::tests::campaign(0, 8); + let location = campaign.actors[&campaign.player_actor_id] + .location_id + .clone(); + campaign.actors.insert( + "refugee-one".into(), + actor("refugee-one", "Refugee One", &location), + ); + campaign.actors.insert( + "refugee-two".into(), + actor("refugee-two", "Refugee Two", &location), + ); + campaign.actors.insert( + "relay-observer".into(), + actor("relay-observer", "Relay Observer", &location), + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut(&campaign.player_actor_id) + .unwrap() + .simulation_eligible = false; + campaign + } + + #[tokio::test] + async fn address_resolver_selects_exact_supplied_respondents() { + let campaign = addressed_campaign(); + let (plan, receipts) = resolve_speech_addresses( + Arc::new(AddressAwareModel), + "flash", + &campaign, + &campaign.player_actor_id, + "Refugee One and Refugee Two: answer separately. Relay Observer, listen.", + ) + .await + .unwrap(); + assert_eq!( + plan.persona_response_actor_ids, + BTreeSet::from(["refugee-one".into(), "refugee-two".into()]) + ); + assert_eq!(receipts.len(), 1); + } + + #[tokio::test] + async fn exact_addressees_receive_response_duty_while_observers_keep_agency() { + let mut campaign = addressed_campaign(); + let speech = "Refugee One and Refugee Two: answer separately."; + campaign.transcript.push(NarrativeTurn { + revision: campaign.revision, + at: Utc::now(), + speaker: campaign.player_actor_id.clone(), + text: speech.into(), + persona_response_actor_ids: BTreeSet::from([ + "refugee-one".into(), + "refugee-two".into(), + ]), + }); + let summary = format!("{} says: {speech}", campaign.player_actor_id); + let engine = PersonaProjectionEngine { + model: Arc::new(AddressAwareModel), + permit: Arc::new(AllowAllPermit), + projector_model: "flash".into(), + persona_model: "pro".into(), + interpreter_model: "flash".into(), + }; + let wave = appraise_present(engine, &campaign, &summary).await.unwrap(); + assert_eq!(wave.reactions.len(), 3); + for actor_id in ["refugee-one", "refugee-two"] { + assert_eq!( + wave.reactions + .iter() + .find(|reaction| reaction.actor_id == actor_id) + .and_then(|reaction| reaction.speech.as_deref()), + Some("that memory is mine alone") + ); + } + assert!( + wave.reactions + .iter() + .find(|reaction| reaction.actor_id == "relay-observer") + .unwrap() + .speech + .is_none() + ); + } +} diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 80a1369..b23f8ed 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -694,6 +694,19 @@ to their own snapshot versions. Initiative selects compatible reactions for a later `WorldCommand`; it does not suppress perception for actors outside the current conversational focus. +Player speech first passes through a small private address projection against +the exact co-present, simulation-eligible actor catalog. The projection may +select only supplied actor IDs and identifies only actors from whom the speech +actually requests a response; people who are merely mentioned or able to hear +remain observers. `WorldKernel` validates co-presence and simulation custody, +then commits the selected response set with the exact speech turn. Browser and +native callers cannot supply those IDs. Each actor's permitted slice carries +either `direct_response_expected` or `present_observer`, while every present +actor still appraises the event. A direct addressee must emit speech or choose +typed deliberate silence. The latter lowers to deterministic visible refusal; +it is not a free-text effect lane. The complete reaction wave still validates +and commits atomically. + The primary player event commits before optional presence casting, reaction appraisal and initiative. A failure in those later stages is stored as a rejected-proposal receipt and returned as an accepted committed result diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 8b6ae13..b220b37 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -734,6 +734,18 @@ world turns, or read historical `narration_projection.v1` rows. This removes two calls per live turn and makes display-layer invention of names, dialogue, injuries, participants, or actions structurally impossible. +Player speech address is also typed before it can steer a reaction. A compact +Flash projection receives only the exact co-present simulation-eligible actor +IDs and names, bounded recent public turns, prior conversational focus, and the +new utterance. It returns the exact Persona actors from whom the utterance asks +for a response. `WorldKernel` revalidates those IDs and commits them with the +speech `NarrativeTurn`; neither Eve nor the native client may submit them. +Every present actor continues to appraise the turn, but the private actor slice +now distinguishes `direct_response_expected` from `present_observer`. A direct +addressee must speak or select typed deliberate silence. The kernel lowers +silence to a deterministic visible refusal, so no free-text gesture lane can +claim an unassessed world effect. + `ghostlight-campaign-inspect` is a read-only typed-store witness. It reports the latest strategic cover, attributed appraisals and inactions, activity outcomes, events, news, subject continuity, and model receipt/token metadata without @@ -952,6 +964,10 @@ Pending NPC actions are revision-scoped reaction-window state. The next reaction wave atomically replaces the set, and the kernel resolves an action only when the current last event is the exact current-revision reaction wave. Same-wave assessment correction remains possible; cross-turn rebasing is not. +The same reaction commit validates its response duty against the exact +committed source turn. A missing directly addressed Persona or a null response +aborts the whole wave without private deltas, transcript additions, initiative, +or revision change; observers may still choose to speak from their own goals. Population fission now composes admitted child entities, stable child identities, one lineage split, exact resource custody transfers, and exact diff --git a/notes/ghostlight-implementation-plan.md b/notes/ghostlight-implementation-plan.md index a19c403..f10bac6 100644 --- a/notes/ghostlight-implementation-plan.md +++ b/notes/ghostlight-implementation-plan.md @@ -118,6 +118,15 @@ browser renders these projections as labeled, escaped human-readable controls, and the player story lowers exact committed transcript turns without a generative rewrite. +Foreground speech now carries explicit response ownership. A private bounded +address stage selects only exact co-present Persona IDs; `WorldKernel` commits +that set with the speech turn and projects direct-addressee versus observer +roles into parallel Persona appraisal. Direct addressees must speak or choose +typed deliberate silence, which the kernel lowers deterministically. This +closes the live failure where every present actor perceived a direct question +but all Interpreters returned `speech: null`, without forcing observers to seize +conversational focus or admitting a free-text reaction-effect lane. + World transition authority is now a closed semantic mutation algebra rather than four empirical effect universes. Foreground attempts, NPC initiative, reaction appraisal, strategic actor/institution/Gestalt outcomes, waits,