Keep addressed Gestalt members in the originating turn
This commit is contained in:
parent
cabf209c10
commit
64f183e753
5 changed files with 397 additions and 69 deletions
|
|
@ -880,9 +880,11 @@ pub struct NarrativeTurn {
|
|||
pub at: DateTime<Utc>,
|
||||
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.
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub persona_response_actor_ids: BTreeSet<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
domain::{Campaign, GestaltPresencePlan},
|
||||
domain::{Campaign, GestaltPresencePlan, GestaltPromotion},
|
||||
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
|
@ -118,7 +118,7 @@ impl GestaltPresencePlanner {
|
|||
"The immediately committed event does not admit a new canonical person. The individuations array must be empty. Cast only supplied existing members."
|
||||
};
|
||||
let base_prompt = format!(
|
||||
"Cast reversible Persona population presence for the next scene after this event. The purpose is to make causal continuity visible without crowding the scene or inventing coincidence. Every materialized_members entry is an already-individualized canonical person, including their stable public identity and current actor state. Never individuate another instance, substitute, or namesake for a supplied materialized person; addressed_existing_actor_ids already satisfy the exact direct addresses resolved before this stage. Promote an existing dormant member when their exact durable history makes them individually relevant. When a nearby dormant member has a reciprocal player relationship and an unresolved callback signal such as an obligation, memory, or goal, promote the single strongest earned callback unless the event makes their presence implausible or dramatically harmful. An ordinary shared-location event is enough opportunity for an earned callback; do not require the player to ask for that person. Prefer an existing person over anonymous individuation whenever their exact delta supports the scene. Return no promotion when there is no earned callback or the current event conflicts with it. {individuation_instruction} Demote a materialized member when they are no longer scene-relevant. Never place a promoted or individuated member outside the player location. Aggregate deltas must remain empty; population learning requires separate review. Emit the exact JSON schema.\nSCHEMA:\n{}\nCANDIDATES:\n{}\nEVENT:\n{}",
|
||||
"Cast reversible Persona population presence for the next scene after this event. The purpose is to make causal continuity visible without crowding the scene or inventing coincidence. Every materialized_members entry is an already-individualized canonical person, including their stable public identity and current actor state. Never individuate another instance, substitute, or namesake for a supplied materialized person; addressed_existing_actor_ids already satisfy the exact direct addresses resolved before this stage. Promote an existing dormant member when their exact durable history makes them individually relevant. When a nearby dormant member has a reciprocal player relationship and an unresolved callback signal such as an obligation, memory, or goal, promote the single strongest earned callback unless the event makes their presence implausible or dramatically harmful. An ordinary shared-location event is enough opportunity for an earned callback; do not require the player to ask for that person. Prefer an existing person over anonymous individuation whenever their exact delta supports the scene. Return no promotion when there is no earned callback or the current event conflicts with it. {individuation_instruction} Demote a materialized member when they are no longer scene-relevant. Select exact supplied member, actor, and gestalt IDs. Ghostlight binds versions and occupancy from the canonical snapshot after selection; those metadata fields are not your decision. Aggregate deltas must remain empty; population learning requires separate review. Emit the exact JSON schema.\nSCHEMA:\n{}\nCANDIDATES:\n{}\nEVENT:\n{}",
|
||||
serde_json::to_string_pretty(&schema)?,
|
||||
candidates,
|
||||
event_summary
|
||||
|
|
@ -146,7 +146,8 @@ impl GestaltPresencePlanner {
|
|||
.clone()
|
||||
.ok_or_else(|| anyhow!("presence planner produced no plan"))
|
||||
.and_then(|value| serde_json::from_value(value).map_err(Into::into))
|
||||
.and_then(|plan| {
|
||||
.and_then(|mut plan| {
|
||||
bind_presence_authority(campaign, &mut plan, player_location)?;
|
||||
validate_plan(campaign, &plan, player_location, event_summary)?;
|
||||
Ok(plan)
|
||||
});
|
||||
|
|
@ -166,7 +167,7 @@ impl GestaltPresencePlanner {
|
|||
.unwrap_or_else(|| "unavailable".into());
|
||||
receipts.push(output.receipt);
|
||||
correction = format!(
|
||||
"\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS PRESENCE PLAN: {error}\nPREVIOUS PLAN:\n{rejected}\nReturn one corrected complete plan against the same snapshot. Promote only exact nearby_dormant_members, demote only exact materialized_members, and copy every gestalt, member, actor, location, and version from the supplied candidates."
|
||||
"\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS PRESENCE PLAN: {error}\nPREVIOUS PLAN:\n{rejected}\nReturn one corrected complete plan against the same snapshot. Promote only exact nearby_dormant_members and demote only exact materialized_members. Ghostlight binds canonical versions and occupancy after your semantic selection."
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
|
|
@ -179,6 +180,83 @@ impl GestaltPresencePlanner {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bind revision, ownership, and occupancy fields from canonical state after
|
||||
/// the model has selected semantic presence changes. Those fields are kernel
|
||||
/// authority, not facts a Persona stage is expected to copy correctly.
|
||||
fn bind_presence_authority(
|
||||
campaign: &Campaign,
|
||||
plan: &mut GestaltPresencePlan,
|
||||
player_location: &str,
|
||||
) -> Result<()> {
|
||||
for promotion in &mut plan.promotions {
|
||||
let member = campaign
|
||||
.gestalt_members
|
||||
.get(&promotion.member_id)
|
||||
.ok_or_else(|| anyhow!("presence plan invented a member"))?;
|
||||
let gestalt = campaign
|
||||
.gestalts
|
||||
.get(&member.gestalt_id)
|
||||
.ok_or_else(|| anyhow!("presence member belongs to an unknown gestalt"))?;
|
||||
promotion.gestalt_id = member.gestalt_id.clone();
|
||||
promotion.expected_gestalt_version = gestalt.version;
|
||||
promotion.expected_member_version = member.version;
|
||||
promotion.location_id =
|
||||
crate::resolution::dormant_member_location(campaign, &promotion.member_id)?;
|
||||
}
|
||||
for individuation in &mut plan.individuations {
|
||||
let gestalt = campaign
|
||||
.gestalts
|
||||
.get(&individuation.gestalt_id)
|
||||
.ok_or_else(|| anyhow!("presence plan invented a gestalt"))?;
|
||||
individuation.expected_gestalt_version = gestalt.version;
|
||||
individuation.location_id = player_location.to_owned();
|
||||
individuation.member.schema = "ghostlight.gestalt_member_delta.v1".into();
|
||||
individuation.member.gestalt_id = individuation.gestalt_id.clone();
|
||||
individuation.member.version = 0;
|
||||
individuation.member.last_location_id = Some(player_location.to_owned());
|
||||
individuation.member.materialized_actor_id = None;
|
||||
individuation.member.last_relevant_revision = 0;
|
||||
individuation.member.relevance_lease_until_revision = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A player addressing a known nearby folded person is itself sufficient
|
||||
/// foreground relevance. Materializing that exact person is deterministic;
|
||||
/// the optional presence model does not get a veto.
|
||||
pub fn required_addressed_promotions(campaign: &Campaign) -> Result<GestaltPresencePlan> {
|
||||
let Some(turn) = campaign.transcript.last().filter(|turn| {
|
||||
turn.revision == campaign.revision && turn.speaker == campaign.player_actor_id
|
||||
}) else {
|
||||
return Ok(GestaltPresencePlan::default());
|
||||
};
|
||||
let mut promotions = Vec::new();
|
||||
for subject_id in &turn.persona_response_actor_ids {
|
||||
if campaign.actors.contains_key(subject_id) {
|
||||
continue;
|
||||
}
|
||||
let member_id = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)
|
||||
.ok_or_else(|| anyhow!("addressed Persona subject is neither present nor folded"))?;
|
||||
let member = &campaign.gestalt_members[member_id];
|
||||
let gestalt = campaign
|
||||
.gestalts
|
||||
.get(&member.gestalt_id)
|
||||
.ok_or_else(|| anyhow!("addressed member belongs to an unknown gestalt"))?;
|
||||
promotions.push(GestaltPromotion {
|
||||
gestalt_id: member.gestalt_id.clone(),
|
||||
expected_gestalt_version: gestalt.version,
|
||||
member_id: member.id.clone(),
|
||||
expected_member_version: member.version,
|
||||
location_id: crate::resolution::dormant_member_location(campaign, member_id)?,
|
||||
});
|
||||
}
|
||||
Ok(GestaltPresencePlan {
|
||||
individuations: Vec::new(),
|
||||
promotions,
|
||||
demotions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn constrain_presence_schema(
|
||||
schema: &mut serde_json::Value,
|
||||
nearby_gestalt_ids: &BTreeSet<String>,
|
||||
|
|
@ -410,7 +488,7 @@ mod tests {
|
|||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -420,7 +498,6 @@ mod tests {
|
|||
|
||||
struct CorrectingPresenceModel {
|
||||
calls: AtomicUsize,
|
||||
saw_semantic_correction: AtomicBool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -442,32 +519,17 @@ mod tests {
|
|||
|
||||
#[async_trait]
|
||||
impl ModelPort for CorrectingPresenceModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
let call = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if call == 0 {
|
||||
return Ok(serde_json::json!({
|
||||
"individuations":[],
|
||||
"promotions":[{
|
||||
"gestalt_id":"nearby-leaf",
|
||||
"expected_gestalt_version":0,
|
||||
"member_id":"mira",
|
||||
"expected_member_version":99,
|
||||
"location_id":"center"
|
||||
}],
|
||||
"demotions":[]
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
self.saw_semantic_correction.store(
|
||||
request
|
||||
.lived_stream
|
||||
.contains("LOCAL VALIDATOR REJECTED THE PREVIOUS PRESENCE PLAN")
|
||||
&& request.lived_stream.contains("expected_member_version"),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
async fn run(&self, _request: &ModelStageRequest) -> Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(serde_json::json!({
|
||||
"individuations":[],
|
||||
"promotions":[],
|
||||
"promotions":[{
|
||||
"gestalt_id":"nearby-leaf",
|
||||
"expected_gestalt_version":98,
|
||||
"member_id":"mira",
|
||||
"expected_member_version":99,
|
||||
"location_id":"center"
|
||||
}],
|
||||
"demotions":[]
|
||||
})
|
||||
.to_string())
|
||||
|
|
@ -670,7 +732,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn presence_planner_corrects_one_semantic_failure_against_the_same_snapshot() {
|
||||
async fn presence_planner_binds_authority_fields_from_the_snapshot() {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
campaign.gestalts.insert(
|
||||
"nearby-leaf".into(),
|
||||
|
|
@ -714,7 +776,6 @@ mod tests {
|
|||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
let model = Arc::new(CorrectingPresenceModel {
|
||||
calls: AtomicUsize::new(0),
|
||||
saw_semantic_correction: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
let (plan, receipts) = GestaltPresencePlanner {
|
||||
|
|
@ -725,13 +786,18 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(plan.promotions.is_empty());
|
||||
assert_eq!(model.calls.load(Ordering::SeqCst), 2);
|
||||
assert!(model.saw_semantic_correction.load(Ordering::SeqCst));
|
||||
assert_eq!(receipts.len(), 2);
|
||||
assert_eq!(receipts[0].validation_result, "semantic_invalid");
|
||||
assert_eq!(receipts[0].snapshot_binding, receipts[1].snapshot_binding);
|
||||
assert!(receipts[1].local_validation_error.is_none());
|
||||
assert_eq!(model.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(receipts.len(), 1);
|
||||
assert_eq!(
|
||||
plan.promotions,
|
||||
vec![GestaltPromotion {
|
||||
gestalt_id: "nearby-leaf".into(),
|
||||
expected_gestalt_version: 0,
|
||||
member_id: "mira".into(),
|
||||
expected_member_version: 0,
|
||||
location_id: "center".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -377,16 +377,21 @@ fn execute(
|
|||
.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)
|
||||
{
|
||||
let eligible_present =
|
||||
campaign.actors.get(response_actor_id).is_some_and(|actor| {
|
||||
actor.location_id == speaker.location_id
|
||||
&& campaign
|
||||
.agency_profiles
|
||||
.get(response_actor_id)
|
||||
.is_none_or(|profile| profile.simulation_eligible)
|
||||
});
|
||||
let eligible_folded =
|
||||
crate::resolution::dormant_member_id_for_subject(&campaign, response_actor_id)
|
||||
.and_then(|member_id| {
|
||||
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) {
|
||||
return Err(KernelError::Invalid(
|
||||
"speech response actor is not an eligible present Persona".into(),
|
||||
));
|
||||
|
|
@ -5270,6 +5275,109 @@ mod tests {
|
|||
assert!(store.keys("world_commit_receipt.v1").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn speech_can_bind_a_nearby_folded_person_then_materialize_that_exact_member() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
|
||||
let kernel = WorldKernel::start(store);
|
||||
let mut seed = campaign();
|
||||
seed.gestalts.insert(
|
||||
"refugees".into(),
|
||||
GestaltPersonaState {
|
||||
schema: "ghostlight.gestalt_persona_state.v1".into(),
|
||||
id: "refugees".into(),
|
||||
name: "Refugees".into(),
|
||||
version: 4,
|
||||
home_location_id: "room".into(),
|
||||
shared_capabilities: BTreeSet::new(),
|
||||
shared_knowledge: BTreeSet::new(),
|
||||
resources: BTreeSet::new(),
|
||||
goals: vec![],
|
||||
pressures: vec![],
|
||||
},
|
||||
);
|
||||
seed.gestalt_members.insert(
|
||||
"water-cart-taren".into(),
|
||||
GestaltMemberDelta {
|
||||
schema: "ghostlight.gestalt_member_delta.v1".into(),
|
||||
id: "water-cart-taren".into(),
|
||||
gestalt_id: "refugees".into(),
|
||||
version: 7,
|
||||
name: "Taren".into(),
|
||||
capability_additions: BTreeSet::new(),
|
||||
capability_removals: BTreeSet::new(),
|
||||
knowledge_additions: BTreeSet::new(),
|
||||
knowledge_removals: BTreeSet::new(),
|
||||
equipment: BTreeSet::from(["water handcart".into()]),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec![],
|
||||
memories: vec!["Ash repaired the coupling".into()],
|
||||
last_location_id: Some("room".into()),
|
||||
materialized_actor_id: None,
|
||||
last_relevant_revision: 0,
|
||||
relevance_lease_until_revision: 0,
|
||||
},
|
||||
);
|
||||
crate::resolution::ensure_agency_profiles(&mut seed);
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed,
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let spoken = kernel
|
||||
.command(WorldCommand::Speak {
|
||||
expected_revision: 0,
|
||||
actor_id: "player".into(),
|
||||
text: "Taren with the water handcart, is the coupling holding?".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::from(["member:water-cart-taren".into()]),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let CommandResult::Committed { campaign, .. } = spoken else {
|
||||
panic!("speech did not commit")
|
||||
};
|
||||
assert!(!campaign.actors.contains_key("member:water-cart-taren"));
|
||||
let plan = crate::gestalt::required_addressed_promotions(&campaign).unwrap();
|
||||
assert_eq!(plan.promotions.len(), 1);
|
||||
assert_eq!(plan.promotions[0].expected_gestalt_version, 4);
|
||||
assert_eq!(plan.promotions[0].expected_member_version, 7);
|
||||
|
||||
let reconciled = kernel
|
||||
.command(WorldCommand::ReconcileGestaltPresence {
|
||||
expected_revision: 1,
|
||||
reason: "player says: Taren with the water handcart, is the coupling holding?"
|
||||
.into(),
|
||||
plan,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let CommandResult::Committed { campaign, .. } = reconciled else {
|
||||
panic!("addressed member was not materialized")
|
||||
};
|
||||
assert!(campaign.actors.contains_key("member:water-cart-taren"));
|
||||
assert_eq!(
|
||||
campaign.gestalt_members["water-cart-taren"]
|
||||
.materialized_actor_id
|
||||
.as_deref(),
|
||||
Some("member:water-cart-taren")
|
||||
);
|
||||
assert!(
|
||||
campaign
|
||||
.transcript
|
||||
.last()
|
||||
.unwrap()
|
||||
.persona_response_actor_ids
|
||||
== BTreeSet::from(["member:water-cart-taren".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn npc_speech_does_not_impersonate_player_activity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use ghostlight_dungeon::{
|
|||
ActionIntent, Campaign, GestaltFissionPreview, RegionExpansionPreview,
|
||||
RejectedProposalReceipt, WorldCommand,
|
||||
},
|
||||
gestalt::GestaltPresencePlanner,
|
||||
gestalt::{GestaltPresencePlanner, required_addressed_promotions},
|
||||
idunn_health::{GHOSTLIGHT_IDUNN_HEALTH_CONTRACT, IdunnHealthPublisher},
|
||||
kernel::{CommandResult, KernelError},
|
||||
mesh::{
|
||||
|
|
@ -4745,12 +4745,56 @@ async fn command(
|
|||
});
|
||||
let mut reaction_campaign = campaign.clone();
|
||||
let mut presence_result = None;
|
||||
match required_addressed_promotions(&reaction_campaign) {
|
||||
Ok(plan) if !plan.promotions.is_empty() => {
|
||||
match runtime
|
||||
.kernel
|
||||
.command(WorldCommand::ReconcileGestaltPresence {
|
||||
expected_revision: reaction_campaign.revision,
|
||||
reason: summary.clone(),
|
||||
plan,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(committed @ CommandResult::Committed { .. }) => {
|
||||
if let CommandResult::Committed { campaign, .. } =
|
||||
&committed
|
||||
{
|
||||
reaction_campaign = campaign.clone();
|
||||
}
|
||||
presence_result = Some(committed);
|
||||
}
|
||||
Ok(_) => unreachable!(),
|
||||
Err(error) => {
|
||||
return committed_after_failure(
|
||||
&state,
|
||||
&runtime,
|
||||
&result,
|
||||
&format!("{command_kind}.addressed_gestalt_presence"),
|
||||
error.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
return committed_after_failure(
|
||||
&state,
|
||||
&runtime,
|
||||
&result,
|
||||
&format!("{command_kind}.addressed_gestalt_presence"),
|
||||
error.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if !campaign.gestalts.is_empty() {
|
||||
let planner = GestaltPresencePlanner {
|
||||
model: model.clone(),
|
||||
model_name: MODEL_FAST.into(),
|
||||
};
|
||||
match planner.plan(campaign, &summary).await {
|
||||
match planner.plan(&reaction_campaign, &summary).await {
|
||||
Ok((plan, receipts)) => {
|
||||
for receipt in receipts {
|
||||
let _ = runtime.store.insert(
|
||||
|
|
@ -4765,7 +4809,7 @@ async fn command(
|
|||
|| !plan.demotions.is_empty()
|
||||
{
|
||||
match runtime.kernel.command(WorldCommand::ReconcileGestaltPresence {
|
||||
expected_revision: campaign.revision,
|
||||
expected_revision: reaction_campaign.revision,
|
||||
reason: summary.clone(),
|
||||
plan,
|
||||
}).await {
|
||||
|
|
@ -4777,27 +4821,31 @@ async fn command(
|
|||
}
|
||||
Ok(_) => unreachable!(),
|
||||
Err(error) => {
|
||||
return committed_after_failure(
|
||||
&state,
|
||||
tracing::warn!(
|
||||
stage = %format!("{command_kind}.gestalt_presence"),
|
||||
%error,
|
||||
"optional post-commit presence proposal rejected; continuing present-actor appraisal"
|
||||
);
|
||||
record_rejected_proposal(
|
||||
&runtime,
|
||||
&result,
|
||||
&format!("{command_kind}.gestalt_presence"),
|
||||
error.to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return committed_after_failure(
|
||||
&state,
|
||||
tracing::warn!(
|
||||
stage = %format!("{command_kind}.gestalt_presence_planner"),
|
||||
%error,
|
||||
"optional post-commit presence planning failed; continuing present-actor appraisal"
|
||||
);
|
||||
record_rejected_proposal(
|
||||
&runtime,
|
||||
&result,
|
||||
&format!("{command_kind}.gestalt_presence_planner"),
|
||||
error.to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub async fn resolve_speech_addresses(
|
|||
.actors
|
||||
.get(speaker_actor_id)
|
||||
.ok_or_else(|| anyhow!("speech actor is unknown"))?;
|
||||
let candidates = campaign
|
||||
let mut candidates = campaign
|
||||
.actors
|
||||
.values()
|
||||
.filter(|actor| {
|
||||
|
|
@ -44,8 +44,31 @@ pub async fn resolve_speech_addresses(
|
|||
.get(&actor.id)
|
||||
.is_none_or(|profile| profile.simulation_eligible)
|
||||
})
|
||||
.map(|actor| (actor.id.clone(), actor.name.clone()))
|
||||
.map(|actor| {
|
||||
(
|
||||
actor.id.clone(),
|
||||
serde_json::json!({
|
||||
"public_name": actor.name,
|
||||
"presence": "materialized_actor",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
candidates.extend(campaign.gestalt_members.values().filter_map(|member| {
|
||||
if member.materialized_actor_id.is_some()
|
||||
|| !crate::resolution::dormant_member_location(campaign, &member.id)
|
||||
.is_ok_and(|location| location == speaker.location_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
format!("member:{}", member.id),
|
||||
serde_json::json!({
|
||||
"public_name": member.name,
|
||||
"presence": "nearby_folded_person",
|
||||
}),
|
||||
))
|
||||
}));
|
||||
if candidates.is_empty() {
|
||||
return Ok((
|
||||
SpeechAddressPlan {
|
||||
|
|
@ -88,7 +111,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, 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{}",
|
||||
"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{}",
|
||||
serde_json::to_string(&schema)?,
|
||||
serde_json::to_string(&candidates)?,
|
||||
serde_json::to_string(&previous_focus)?,
|
||||
|
|
@ -332,7 +355,7 @@ fn canonical_reaction_turn<'a>(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
domain::{ActorState, GestaltMemberDelta, NarrativeTurn},
|
||||
domain::{ActorState, GestaltMemberDelta, GestaltPersonaState, NarrativeTurn},
|
||||
model::ModelStageRequest,
|
||||
persona::{AllowAllPermit, PersonaProjectionEngine},
|
||||
};
|
||||
|
|
@ -340,6 +363,7 @@ mod tests {
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct AddressAwareModel;
|
||||
struct DormantAddressModel;
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for AddressAwareModel {
|
||||
|
|
@ -388,6 +412,23 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for DormantAddressModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
assert_eq!(request.stage, "speech_address_resolver");
|
||||
assert!(request.lived_stream.contains("nearby_folded_person"));
|
||||
assert!(request.lived_stream.contains("member:water-cart-taren"));
|
||||
Ok(serde_json::json!({
|
||||
"persona_response_actor_ids":["member:water-cart-taren"]
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn provider(&self) -> &'static str {
|
||||
"fixture"
|
||||
}
|
||||
}
|
||||
|
||||
fn actor(id: &str, name: &str, location_id: &str) -> ActorState {
|
||||
ActorState {
|
||||
id: id.into(),
|
||||
|
|
@ -500,6 +541,69 @@ mod tests {
|
|||
assert_eq!(receipts.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn address_resolver_can_select_the_exact_nearby_folded_person() {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
let location = campaign.actors[&campaign.player_actor_id]
|
||||
.location_id
|
||||
.clone();
|
||||
campaign.gestalts.insert(
|
||||
"refugees".into(),
|
||||
GestaltPersonaState {
|
||||
schema: "ghostlight.gestalt_persona_state.v1".into(),
|
||||
id: "refugees".into(),
|
||||
name: "Refugees".into(),
|
||||
version: 3,
|
||||
home_location_id: location.clone(),
|
||||
shared_capabilities: BTreeSet::new(),
|
||||
shared_knowledge: BTreeSet::new(),
|
||||
resources: BTreeSet::new(),
|
||||
goals: vec![],
|
||||
pressures: vec![],
|
||||
},
|
||||
);
|
||||
campaign.gestalt_members.insert(
|
||||
"water-cart-taren".into(),
|
||||
GestaltMemberDelta {
|
||||
schema: "ghostlight.gestalt_member_delta.v1".into(),
|
||||
id: "water-cart-taren".into(),
|
||||
gestalt_id: "refugees".into(),
|
||||
version: 7,
|
||||
name: "Taren".into(),
|
||||
capability_additions: BTreeSet::new(),
|
||||
capability_removals: BTreeSet::new(),
|
||||
knowledge_additions: BTreeSet::new(),
|
||||
knowledge_removals: BTreeSet::new(),
|
||||
equipment: BTreeSet::from(["water handcart".into()]),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec![],
|
||||
memories: vec![],
|
||||
last_location_id: Some(location),
|
||||
materialized_actor_id: None,
|
||||
last_relevant_revision: 0,
|
||||
relevance_lease_until_revision: 0,
|
||||
},
|
||||
);
|
||||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
|
||||
let (plan, _) = resolve_speech_addresses(
|
||||
Arc::new(DormantAddressModel),
|
||||
"flash",
|
||||
&campaign,
|
||||
&campaign.player_actor_id,
|
||||
"Taren with the water handcart, is the coupling holding?",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
plan.persona_response_actor_ids,
|
||||
BTreeSet::from(["member:water-cart-taren".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_addressees_receive_response_duty_while_observers_keep_agency() {
|
||||
let mut campaign = addressed_campaign();
|
||||
|
|
|
|||
Loading…
Reference in a new issue