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 f3e2a2f..b501e52 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs @@ -77,6 +77,7 @@ async fn main() -> anyhow::Result<()> { expected_revision: 0, event_summary: event_summary.clone(), reactions: wave.reactions.clone(), + gestalt_reactions: wave.gestalt_reactions.clone(), }) .await?; let total_seconds = started.elapsed().as_secs_f64(); diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index b0b0db5..ed8988e 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -684,6 +684,18 @@ pub struct ActorReaction { pub action_proposals: Vec, } +/// A foreground appraisal owned by one cohesive population subject. Unlike an +/// arena cell, a Gestalt has genuine collective authority and may speak in the +/// plural. Its foreground reaction cannot mutate actor-private state or smuggle +/// an unassessed strategic action through dialogue. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct GestaltReaction { + pub gestalt_id: String, + pub speech: Option, + #[serde(default)] + pub deliberate_silence: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct InstitutionState { pub id: String, @@ -881,10 +893,10 @@ pub struct NarrativeTurn { pub speaker: String, pub text: String, /// Exact model-controlled Persona subject IDs whom this committed speech - /// asks to respond. A nearby folded member keeps their stable future actor - /// ID here while the kernel materializes that same person for appraisal. - /// Other present actors still perceive and appraise the turn, but they are - /// not response-bound. Human-controlled actors never appear here. + /// asks to respond. The legacy field name includes actors, folded named + /// members, and cohesive Gestalts. Other present subjects still perceive + /// and appraise the turn but are not response-bound. Human-controlled + /// actors never appear here. #[serde(default)] pub persona_response_actor_ids: BTreeSet, } @@ -1047,6 +1059,8 @@ pub enum WorldCommand { expected_revision: u64, event_summary: String, reactions: Vec, + #[serde(default)] + gestalt_reactions: Vec, }, ResolveNpcAction { expected_revision: u64, diff --git a/crates/ghostlight-dungeon/src/gestalt.rs b/crates/ghostlight-dungeon/src/gestalt.rs index 5ebeb91..c83ba17 100644 --- a/crates/ghostlight-dungeon/src/gestalt.rs +++ b/crates/ghostlight-dungeon/src/gestalt.rs @@ -232,7 +232,7 @@ pub fn required_addressed_promotions(campaign: &Campaign) -> Result { require_revision(&campaign, expected_revision)?; let witnessed_turn = canonical_witnessed_turn(&campaign, &event_summary)?; @@ -1353,29 +1364,58 @@ fn execute( 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 - { + let mut seen_gestalts = BTreeSet::new(); + for reaction in &gestalt_reactions { + if !seen_gestalts.insert(reaction.gestalt_id.clone()) { return Err(KernelError::Invalid( - "directly addressed Persona produced no observable response".into(), + "Gestalt reacted twice in one wave".into(), + )); + } + if seen.contains(&reaction.gestalt_id) { + return Err(KernelError::Invalid( + "reaction subject appeared as both actor and Gestalt".into(), + )); + } + crate::resolution::validate_active_gestalt_presence_location( + &campaign, + &reaction.gestalt_id, + &player_location, + ) + .map_err(|_| KernelError::Invalid("reaction Gestalt is not present".into()))?; + if let Some(speech) = &reaction.speech { + validate_bounded_text("Gestalt reaction speech", speech, 1_000)?; + } + } + for response_actor_id in &response_expected_actor_ids { + let actor_reaction = reactions + .iter() + .find(|reaction| &reaction.actor_id == response_actor_id); + let gestalt_reaction = gestalt_reactions + .iter() + .find(|reaction| &reaction.gestalt_id == response_actor_id); + let observable = actor_reaction.is_some_and(|reaction| { + reaction + .speech + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || reaction.deliberate_silence + }) || gestalt_reaction.is_some_and(|reaction| { + reaction + .speech + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || reaction.deliberate_silence + }); + if !observable { + return Err(KernelError::Invalid( + "directly addressed Persona is absent or produced no observable response" + .into(), )); } } let source_receipt_id = format!( "reaction-input:{}", - crate::legacy_transition::digest_serializable(&reactions) + crate::legacy_transition::digest_serializable(&(&reactions, &gestalt_reactions)) .map_err(|error| KernelError::Invalid(error.to_string()))? ); let transition = crate::legacy_transition::lower_reaction_wave( @@ -1416,6 +1456,26 @@ fn execute( .pending_world_proposals .extend(reaction.action_proposals); } + for reaction in gestalt_reactions { + if let Some(speech) = reaction.speech { + campaign.transcript.push(NarrativeTurn { + revision: campaign.revision + 1, + at: Utc::now(), + speaker: reaction.gestalt_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.gestalt_id, + text: "deliberately does not answer.".into(), + persona_response_actor_ids: BTreeSet::new(), + }); + } + } refresh_materialized_member_relevance(&mut campaign, seen.iter().map(String::as_str)); campaign.events.push(Event { id: format!("reaction-wave:{}", campaign.revision + 1), @@ -1424,7 +1484,7 @@ fn execute( summary: event_summary, actor_ids: seen.into_iter().collect(), institution_ids: vec![], - gestalt_ids: vec![], + gestalt_ids: seen_gestalts.into_iter().collect(), location_ids: vec![player_location], public_channels: vec![], }); @@ -6161,6 +6221,7 @@ mod tests { expected_revision: 0, event_summary: "player says: Anna, answer me.".into(), reactions: vec![silent], + gestalt_reactions: vec![], }) .await .unwrap_err(); @@ -6186,6 +6247,7 @@ mod tests { private_delta: ActorStateDelta::default(), action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await .unwrap(); @@ -6199,6 +6261,81 @@ mod tests { ); } + #[tokio::test] + async fn directly_addressed_local_gestalt_reacts_as_its_exact_collective_subject() { + 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.gestalts.insert( + "households".into(), + GestaltPersonaState { + schema: "ghostlight.gestalt_persona_state.v1".into(), + id: "households".into(), + name: "Settlement households".into(), + version: 0, + home_location_id: "room".into(), + shared_capabilities: BTreeSet::from(["collective refusal".into()]), + shared_knowledge: BTreeSet::from(["the slate omits five names".into()]), + resources: BTreeSet::new(), + goals: vec!["keep every household traceable".into()], + pressures: vec!["the convoy slate is closing".into()], + }, + ); + seed.transcript.push(NarrativeTurn { + revision: 0, + at: seed.world_time, + speaker: "player".into(), + text: "Households, will you refuse the slate?".into(), + persona_response_actor_ids: BTreeSet::from(["households".into()]), + }); + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed.clone(), + evidence_receipts: vec![], + model_stage_receipts: vec![], + }) + .await + .unwrap(); + + let missing = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "player says: Households, will you refuse the slate?".into(), + reactions: vec![], + gestalt_reactions: vec![], + }) + .await + .unwrap_err(); + assert!(missing.to_string().contains("no observable response")); + + let committed = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "player says: Households, will you refuse the slate?".into(), + reactions: vec![], + gestalt_reactions: vec![GestaltReaction { + gestalt_id: "households".into(), + speech: Some( + "We will refuse a slate that leaves our names untraceable.".into(), + ), + deliberate_silence: false, + }], + }) + .await + .unwrap(); + let CommandResult::Committed { campaign, .. } = committed else { + panic!() + }; + assert_eq!(campaign.revision, 1); + assert_eq!(campaign.transcript.last().unwrap().speaker, "households"); + assert_eq!( + campaign.events.last().unwrap().gestalt_ids, + vec!["households"] + ); + assert_eq!(campaign.gestalts["households"], seed.gestalts["households"]); + } + #[tokio::test] async fn invalid_reaction_rejects_the_entire_wave_without_mutation() { let dir = tempfile::tempdir().unwrap(); @@ -6262,6 +6399,7 @@ mod tests { expected_revision: 0, event_summary: "the player acts".into(), reactions, + gestalt_reactions: vec![], }) .await .is_err() @@ -6345,6 +6483,7 @@ mod tests { }, action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await; assert!( @@ -6364,6 +6503,7 @@ mod tests { }, action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await; assert!( @@ -6383,6 +6523,7 @@ mod tests { }, action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await; assert!( @@ -6446,6 +6587,7 @@ mod tests { private_delta: ActorStateDelta::default(), action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await .unwrap(); @@ -6532,6 +6674,7 @@ mod tests { action_proposals: vec![bert.clone()], }, ], + gestalt_reactions: vec![], }) .await .unwrap(); @@ -6681,6 +6824,7 @@ mod tests { private_delta: ActorStateDelta::default(), action_proposals: vec![proposal.clone()], }], + gestalt_reactions: vec![], }) .await .unwrap(); @@ -6731,6 +6875,7 @@ mod tests { private_delta: ActorStateDelta::default(), action_proposals: vec![], }], + gestalt_reactions: vec![], }) .await .unwrap(); diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 43611d5..9716ccc 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -5077,7 +5077,9 @@ async fn command( } } } - if reaction_campaign.actors.len() > 1 { + if reaction_campaign.actors.len() > 1 + || !reaction_campaign.gestalts.is_empty() + { let engine = PersonaProjectionEngine { model: model.clone(), permit: Arc::new(SnapshotPermit::new( @@ -5105,6 +5107,7 @@ async fn command( expected_revision: reaction_campaign.revision, event_summary: summary, reactions: wave.reactions, + gestalt_reactions: wave.gestalt_reactions, }) .await { diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 66b4528..3244de9 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -19,10 +19,18 @@ pub enum ActorInteractionRole { PresentObserver, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PersonaSubjectKind { + IndividualActor, + CohesiveGestalt, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct PermittedActorSlice { pub actor_id: String, pub location_id: String, + pub subject_kind: PersonaSubjectKind, pub snapshot_binding: String, pub interaction_role: ActorInteractionRole, pub identity_experience: Vec, @@ -470,8 +478,16 @@ fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String { "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." } }; + let ownership_guidance = match slice.subject_kind { + PersonaSubjectKind::IndividualActor => { + "Record only private changes supported by the lived stream and typed context. identity_adoption is null unless the Persona explicitly adopts or presents one public self-identifier in its own speech; when set, copy the exact spoken handle and nothing else. World actions are attempts, not completed effects." + } + PersonaSubjectKind::CohesiveGestalt => { + "This is a cohesive population appraisal. Extract only collective speech or deliberate silence. Every actor-private delta must remain empty, identity_adoption must be null, and world_actions must be empty; strategic population action uses the cell-resolution path, not foreground dialogue." + } + }; format!( - "{response_guidance} Record only private changes supported by the lived stream and typed context. identity_adoption is null unless the Persona explicitly adopts or presents one public self-identifier in its own speech; when set, copy the exact spoken handle and nothing else. 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} {ownership_guidance} 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::>() @@ -532,8 +548,16 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S .join(", ") ) }; + let subject_grounding = match slice.subject_kind { + PersonaSubjectKind::IndividualActor => { + "You are one situated person. Speak and choose only for yourself." + } + PersonaSubjectKind::CohesiveGestalt => { + "You are a cohesive population Persona. You may speak plurally only from genuinely shared state. Do not invent unanimity about exceptions, speak for named individuals, or claim authority outside the supplied collective." + } + }; format!( - "{projection}\n\nYour active self-identity: {identity}. In your social world, {established_peer_identities}. {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." + "{projection}\n\n{subject_grounding} Your active self-identity: {identity}. In your social world, {established_peer_identities}. {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." ) } @@ -541,6 +565,14 @@ fn validate_actor_proposals( slice: &PermittedActorSlice, proposals: &PersonaProposalBundle, ) -> Result<()> { + if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt) + && (proposals.private_delta != crate::domain::ActorStateDelta::default() + || !proposals.world_actions.is_empty()) + { + return Err(anyhow!( + "cohesive foreground Gestalt appraisal may emit only speech or deliberate silence" + )); + } if matches!( slice.interaction_role, ActorInteractionRole::DirectResponseExpected @@ -2816,6 +2848,27 @@ fn constrain_interpreter_schema( .ok_or_else(|| anyhow!("Persona proposal schema has no identity adoption"))?; *identity_adoption = serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":160}); + if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt) { + for field in [ + "memories_add", + "conditions_add", + "conditions_remove", + "goals_add", + ] { + let value = schema + .pointer_mut(&format!("/$defs/ActorStateDelta/properties/{field}")) + .ok_or_else(|| anyhow!("Persona proposal schema has no {field}"))?; + *value = serde_json::json!({"type":"array","maxItems":0,"items":{"type":"string"}}); + } + *schema + .pointer_mut("/$defs/ActorStateDelta/properties/relationship_updates") + .ok_or_else(|| anyhow!("Persona proposal schema has no relationship updates"))? = + serde_json::json!({"type":"object","maxProperties":0,"additionalProperties":false}); + *schema + .pointer_mut("/$defs/ActorStateDelta/properties/identity_adoption") + .ok_or_else(|| anyhow!("Persona proposal schema has no identity adoption"))? = + serde_json::json!({"type":"null"}); + } let root = schema .as_object_mut() .ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?; @@ -2831,6 +2884,12 @@ fn constrain_interpreter_schema( "deliberate_silence".into(), serde_json::json!({"type":"boolean"}), ); + if matches!(slice.subject_kind, PersonaSubjectKind::CohesiveGestalt) { + properties.insert( + "world_actions".into(), + serde_json::json!({"type":"array","maxItems":0,"items":{"$ref":"#/$defs/WorldActionProposal"}}), + ); + } root.entry("allOf") .or_insert_with(|| serde_json::json!([])) .as_array_mut() @@ -4215,6 +4274,7 @@ mod tests { let slice = PermittedActorSlice { actor_id: "npc".into(), location_id: "room".into(), + subject_kind: PersonaSubjectKind::IndividualActor, snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::PresentObserver, identity_experience: vec!["A tired navigator".into()], @@ -4251,6 +4311,7 @@ mod tests { let mut slice = PermittedActorSlice { actor_id: "npc".into(), location_id: "room".into(), + subject_kind: PersonaSubjectKind::IndividualActor, snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::DirectResponseExpected, identity_experience: vec!["You are an unnamed patient.".into()], @@ -4298,6 +4359,7 @@ mod tests { let slice = PermittedActorSlice { actor_id: "npc".into(), location_id: "room".into(), + subject_kind: PersonaSubjectKind::IndividualActor, snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::PresentObserver, identity_experience: vec!["You are an unnamed patient.".into()], @@ -4392,6 +4454,7 @@ mod tests { .execute(PermittedActorSlice { actor_id: "npc".into(), location_id: "room".into(), + subject_kind: PersonaSubjectKind::IndividualActor, snapshot_binding: "campaign:1:revision:4".into(), interaction_role: ActorInteractionRole::DirectResponseExpected, identity_experience: vec!["You are an unnamed patient.".into()], diff --git a/crates/ghostlight-dungeon/src/turn.rs b/crates/ghostlight-dungeon/src/turn.rs index 58da029..480b4b7 100644 --- a/crates/ghostlight-dungeon/src/turn.rs +++ b/crates/ghostlight-dungeon/src/turn.rs @@ -1,9 +1,10 @@ use crate::{ - domain::{ActorReaction, Campaign}, + domain::{ActorReaction, Campaign, GestaltReaction}, model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage}, persistence::CampaignStore, persona::{ ActorInteractionRole, ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine, + PersonaSubjectKind, }, }; use anyhow::{Result, anyhow}; @@ -14,13 +15,13 @@ 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. + /// Exact present, model-controlled Persona subjects from whom the utterance + /// requests an answer. Mentioned subjects and passive hearers are omitted. 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 +/// present-subject 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, @@ -69,6 +70,21 @@ pub async fn resolve_speech_addresses( }), )) })); + candidates.extend(campaign.gestalts.values().filter_map(|gestalt| { + crate::resolution::validate_active_gestalt_presence_location( + campaign, + &gestalt.id, + &speaker.location_id, + ) + .ok()?; + Some(( + gestalt.id.clone(), + serde_json::json!({ + "public_name": gestalt.name, + "presence": "cohesive_population", + }), + )) + })); if candidates.is_empty() { return Ok(( SpeechAddressPlan { @@ -111,7 +127,7 @@ pub async fn resolve_speech_addresses( "items":{"type":"string","enum":allowed_ids}, }); let prompt = format!( - "You are Ghostlight's private scene-address resolver. Decide only which supplied present Persona subjects this exact speech directly asks to answer now. A nearby_folded_person is a persistent known individual currently represented through their population; selecting their exact member ID lets Ghostlight materialize that same person before appraisal. 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 IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT PERSONA SUBJECTS:\n{}\nPREVIOUS FOCUS:\n{}\nRECENT PUBLIC TURNS:\n{}\nSPEAKER ACTOR ID:\n{}\nEXACT SPEECH:\n{}", + "You are Ghostlight's private scene-address resolver. Decide only which supplied present Persona subjects this exact speech directly asks to answer now. A nearby_folded_person is a persistent known individual currently represented through their population; selecting their exact member ID lets Ghostlight materialize that same person before appraisal. A cohesive_population is a genuine collective subject and may be directly asked for a plural response. A subject 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 IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT PERSONA SUBJECTS:\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)?, @@ -197,9 +213,15 @@ impl ExecutionPermit for SnapshotPermit { pub struct ReactionWaveOutput { pub reactions: Vec, + pub gestalt_reactions: Vec, pub receipts: Vec, } +enum PresentReactionTerminal { + Actor(String, crate::persona::PersonaTerminalBundle), + Gestalt(String, crate::persona::PersonaTerminalBundle), +} + pub async fn appraise_present( engine: PersonaProjectionEngine, campaign: &Campaign, @@ -224,12 +246,30 @@ pub async fn appraise_present( }) .cloned() .collect(); - let perceived_actors = campaign + let mut perceived_actors = campaign .actors .values() .filter(|actor| actor.location_id == player.location_id) .map(|actor| (actor.id.clone(), actor.name.clone())) .collect::>(); + let local_gestalts = campaign + .gestalts + .values() + .filter(|gestalt| { + crate::resolution::validate_active_gestalt_presence_location( + campaign, + &gestalt.id, + &player.location_id, + ) + .is_ok() + }) + .cloned() + .collect::>(); + perceived_actors.extend( + local_gestalts + .iter() + .map(|gestalt| (gestalt.id.clone(), gestalt.name.clone())), + ); let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(usize::from( campaign.resolution_policy.provider_parallelism.max(1), ))); @@ -255,6 +295,7 @@ pub async fn appraise_present( let slice = PermittedActorSlice { actor_id: actor.id.clone(), location_id: actor.location_id.clone(), + subject_kind: PersonaSubjectKind::IndividualActor, snapshot_binding: snapshot, interaction_role, identity_experience: vec![format!("You are {}.", actor.name)], @@ -279,26 +320,82 @@ pub async fn appraise_present( source_receipt_ids: receipts, }; let terminal = engine.execute(slice).await?; - Ok::<_, anyhow::Error>((actor.id, terminal)) + Ok::<_, anyhow::Error>(PresentReactionTerminal::Actor(actor.id, terminal)) + }); + } + for gestalt in local_gestalts { + let engine = engine.clone(); + let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision); + let event = event_summary.to_owned(); + 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(&gestalt.id) { + ActorInteractionRole::DirectResponseExpected + } else { + ActorInteractionRole::PresentObserver + }; + jobs.spawn(async move { + let _provider_slot = semaphore + .acquire_owned() + .await + .map_err(|_| anyhow!("provider concurrency gate closed"))?; + let slice = PermittedActorSlice { + actor_id: gestalt.id.clone(), + location_id: gestalt.home_location_id.clone(), + subject_kind: PersonaSubjectKind::CohesiveGestalt, + snapshot_binding: snapshot, + interaction_role, + identity_experience: vec![format!( + "You are {}, a cohesive population represented through genuinely shared state.", + gestalt.name + )], + reserved_public_identities: perceived_actors.values().cloned().collect(), + memories: vec![], + perceived_events: vec![event], + perceived_actors, + relationships: vec![], + goals: gestalt.goals, + knowledge: gestalt.shared_knowledge.into_iter().collect(), + capabilities: gestalt.shared_capabilities.into_iter().collect(), + pressures: gestalt.pressures, + affordances: gestalt.resources.into_iter().collect(), + source_receipt_ids: receipts, + }; + let terminal = engine.execute(slice).await?; + Ok::<_, anyhow::Error>(PresentReactionTerminal::Gestalt(gestalt.id, terminal)) }); } let mut reactions = Vec::new(); + let mut gestalt_reactions = Vec::new(); let mut receipts = Vec::new(); while let Some(result) = jobs.join_next().await { - let (actor_id, terminal) = - result.map_err(|e| anyhow!("Persona appraisal task failed: {e}"))??; - receipts.extend(terminal.stage_receipts); - 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, - }); + match result.map_err(|e| anyhow!("Persona appraisal task failed: {e}"))?? { + PresentReactionTerminal::Actor(actor_id, terminal) => { + receipts.extend(terminal.stage_receipts); + 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, + }); + } + PresentReactionTerminal::Gestalt(gestalt_id, terminal) => { + receipts.extend(terminal.stage_receipts); + gestalt_reactions.push(GestaltReaction { + gestalt_id, + speech: terminal.proposals.speech, + deliberate_silence: terminal.proposals.deliberate_silence, + }); + } + } } reactions.sort_by(|a, b| a.actor_id.cmp(&b.actor_id)); + gestalt_reactions.sort_by(|a, b| a.gestalt_id.cmp(&b.gestalt_id)); Ok(ReactionWaveOutput { reactions, + gestalt_reactions, receipts, }) } @@ -364,6 +461,7 @@ mod tests { struct AddressAwareModel; struct DormantAddressModel; + struct CollectiveAddressModel; #[async_trait] impl ModelPort for AddressAwareModel { @@ -429,6 +527,46 @@ mod tests { } } + #[async_trait] + impl ModelPort for CollectiveAddressModel { + async fn run(&self, request: &ModelStageRequest) -> Result { + Ok(match request.stage.as_str() { + "speech_address_resolver" => { + assert!(request.lived_stream.contains("cohesive_population")); + assert!(request.lived_stream.contains("Settlement households")); + serde_json::json!({ + "persona_response_actor_ids":["settlement-households"] + }) + .to_string() + } + "projector" => { + "The households hear a direct request for their shared answer.".into() + } + "persona" => "We will not accept a slate that makes our names disappear.".into(), + "interpreter" => serde_json::json!({ + "private_delta":{ + "memories_add":[], + "identity_adoption":null, + "conditions_add":[], + "conditions_remove":[], + "goals_add":[], + "relationship_updates":{}, + }, + "speech":"We will not accept a slate that makes our names disappear.", + "deliberate_silence":false, + "reaction_priority":5, + "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(), @@ -646,4 +784,74 @@ mod tests { .is_none() ); } + + #[tokio::test] + async fn local_cohesive_gestalt_is_addressable_and_appraises_in_the_foreground_wave() { + let mut campaign = crate::resolution::tests::campaign(0, 8); + let location = campaign.actors[&campaign.player_actor_id] + .location_id + .clone(); + campaign.gestalts.insert( + "settlement-households".into(), + GestaltPersonaState { + schema: "ghostlight.gestalt_persona_state.v1".into(), + id: "settlement-households".into(), + name: "Settlement households".into(), + version: 0, + home_location_id: location, + shared_capabilities: BTreeSet::from(["collective refusal".into()]), + shared_knowledge: BTreeSet::from(["five names lack a traceable path".into()]), + resources: BTreeSet::new(), + goals: vec!["keep every household legible".into()], + pressures: vec!["the slate is closing".into()], + }, + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut(&campaign.player_actor_id) + .unwrap() + .simulation_eligible = false; + let speech = "Settlement households, will you refuse this slate?"; + let (plan, _) = resolve_speech_addresses( + Arc::new(CollectiveAddressModel), + "flash", + &campaign, + &campaign.player_actor_id, + speech, + ) + .await + .unwrap(); + assert_eq!( + plan.persona_response_actor_ids, + BTreeSet::from(["settlement-households".into()]) + ); + campaign.transcript.push(NarrativeTurn { + revision: campaign.revision, + at: Utc::now(), + speaker: campaign.player_actor_id.clone(), + text: speech.into(), + persona_response_actor_ids: plan.persona_response_actor_ids, + }); + let summary = format!("{} says: {speech}", campaign.player_actor_id); + let wave = appraise_present( + PersonaProjectionEngine { + model: Arc::new(CollectiveAddressModel), + permit: Arc::new(AllowAllPermit), + projector_model: "flash".into(), + persona_model: "pro".into(), + interpreter_model: "flash".into(), + }, + &campaign, + &summary, + ) + .await + .unwrap(); + assert!(wave.reactions.is_empty()); + assert_eq!(wave.gestalt_reactions.len(), 1); + assert_eq!( + wave.gestalt_reactions[0].speech.as_deref(), + Some("We will not accept a slate that makes our names disappear.") + ); + } }