Include cohesive Gestalts in foreground reaction waves

This commit is contained in:
Meta Vens 2026-08-25 20:45:15 +02:00
commit 06df9a903a
7 changed files with 476 additions and 42 deletions

View file

@ -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();

View file

@ -684,6 +684,18 @@ pub struct ActorReaction {
pub action_proposals: Vec<WorldActionProposal>,
}
/// 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<String>,
#[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<String>,
}
@ -1047,6 +1059,8 @@ pub enum WorldCommand {
expected_revision: u64,
event_summary: String,
reactions: Vec<ActorReaction>,
#[serde(default)]
gestalt_reactions: Vec<GestaltReaction>,
},
ResolveNpcAction {
expected_revision: u64,

View file

@ -232,7 +232,7 @@ pub fn required_addressed_promotions(campaign: &Campaign) -> Result<GestaltPrese
};
let mut promotions = Vec::new();
for subject_id in &turn.persona_response_actor_ids {
if campaign.actors.contains_key(subject_id) {
if campaign.actors.contains_key(subject_id) || campaign.gestalts.contains_key(subject_id) {
continue;
}
let member_id = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)

View file

@ -391,7 +391,17 @@ fn execute(
crate::resolution::dormant_member_location(&campaign, member_id).ok()
})
.is_some_and(|location| location == speaker.location_id);
if response_actor_id == &actor_id || !(eligible_present || eligible_folded) {
let eligible_gestalt = campaign.gestalts.get(response_actor_id).is_some_and(|_| {
crate::resolution::validate_active_gestalt_presence_location(
&campaign,
response_actor_id,
&speaker.location_id,
)
.is_ok()
});
if response_actor_id == &actor_id
|| !(eligible_present || eligible_folded || eligible_gestalt)
{
return Err(KernelError::Invalid(
"speech response actor is not an eligible present Persona".into(),
));
@ -1305,6 +1315,7 @@ fn execute(
expected_revision,
event_summary,
reactions,
gestalt_reactions,
} => {
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();

View file

@ -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
{

View file

@ -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<String>,
@ -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::<Vec<_>>()
@ -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()],

View file

@ -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<String>,
}
/// 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<dyn ModelPort>,
@ -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<ActorReaction>,
pub gestalt_reactions: Vec<GestaltReaction>,
pub receipts: Vec<ModelStageReceipt>,
}
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::<std::collections::BTreeMap<_, _>>();
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::<Vec<_>>();
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<String> {
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.")
);
}
}