Keep emergent Persona identities distinct
This commit is contained in:
parent
63d3b45910
commit
dff41b255e
5 changed files with 239 additions and 12 deletions
|
|
@ -266,10 +266,31 @@ fn validate_plan(
|
|||
));
|
||||
}
|
||||
let mut members = BTreeSet::new();
|
||||
let addressed_public_identities = automatic_individuation_addressed_actor_ids(campaign)
|
||||
.iter()
|
||||
.filter_map(|actor_id| campaign.actors.get(actor_id))
|
||||
let active_local_gestalt_ids = campaign
|
||||
.gestalts
|
||||
.values()
|
||||
.filter(|gestalt| {
|
||||
crate::resolution::validate_active_gestalt_presence_location(
|
||||
campaign,
|
||||
&gestalt.id,
|
||||
player_location,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.map(|gestalt| gestalt.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let established_public_identities = campaign
|
||||
.actors
|
||||
.values()
|
||||
.filter(|actor| actor.location_id == player_location)
|
||||
.map(|actor| normalized_public_identity(&actor.name))
|
||||
.chain(
|
||||
campaign
|
||||
.gestalt_members
|
||||
.values()
|
||||
.filter(|member| active_local_gestalt_ids.contains(member.gestalt_id.as_str()))
|
||||
.map(|member| normalized_public_identity(&member.name)),
|
||||
)
|
||||
.collect::<BTreeSet<_>>();
|
||||
for individuation in &plan.individuations {
|
||||
let member = &individuation.member;
|
||||
|
|
@ -296,9 +317,9 @@ fn validate_plan(
|
|||
"presence individuation does not match its snapshot"
|
||||
));
|
||||
}
|
||||
if addressed_public_identities.contains(&normalized_public_identity(&member.name)) {
|
||||
if established_public_identities.contains(&normalized_public_identity(&member.name)) {
|
||||
return Err(anyhow!(
|
||||
"presence individuation duplicates an already-addressed actor"
|
||||
"presence individuation duplicates an established local or population identity"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -539,7 +560,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn individuation_cannot_duplicate_an_existing_public_identity() {
|
||||
fn individuation_cannot_duplicate_an_established_population_identity() {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
campaign.revision = 7;
|
||||
campaign.gestalts.insert(
|
||||
|
|
@ -602,7 +623,7 @@ mod tests {
|
|||
at: chrono::Utc::now(),
|
||||
speaker: campaign.player_actor_id.clone(),
|
||||
text: "Taren, tell me whether the regulator is holding.".into(),
|
||||
persona_response_actor_ids: BTreeSet::from(["member:oxygen_patient".into()]),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
let plan = GestaltPresencePlan {
|
||||
|
|
@ -644,7 +665,7 @@ mod tests {
|
|||
validate_plan(&campaign, &plan, "center", &event)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("already-addressed actor")
|
||||
.contains("established local or population identity")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1339,6 +1339,7 @@ fn execute(
|
|||
"reaction identity adoption must copy an exact spoken handle".into(),
|
||||
));
|
||||
}
|
||||
validate_reaction_identity_adoption(&campaign, &reaction.actor_id, identity)?;
|
||||
}
|
||||
if let Some(speech) = &reaction.speech {
|
||||
validate_bounded_text("reaction speech", speech, 1_000)?;
|
||||
|
|
@ -1794,6 +1795,44 @@ fn validate_world_proposal(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_reaction_identity_adoption(
|
||||
campaign: &Campaign,
|
||||
actor_id: &str,
|
||||
identity: &str,
|
||||
) -> Result<(), KernelError> {
|
||||
let actor = campaign
|
||||
.actors
|
||||
.get(actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("reaction actor is unknown".into()))?;
|
||||
let identity = identity.trim().to_lowercase();
|
||||
if actor.name.trim().to_lowercase() == identity {
|
||||
return Ok(());
|
||||
}
|
||||
let local_collision = campaign.actors.values().any(|peer| {
|
||||
peer.id != actor_id
|
||||
&& peer.location_id == actor.location_id
|
||||
&& peer.name.trim().to_lowercase() == identity
|
||||
});
|
||||
let population_collision = campaign
|
||||
.gestalt_members
|
||||
.values()
|
||||
.find(|member| member.materialized_actor_id.as_deref() == Some(actor_id))
|
||||
.is_some_and(|member| {
|
||||
campaign.gestalt_members.values().any(|peer| {
|
||||
peer.id != member.id
|
||||
&& peer.gestalt_id == member.gestalt_id
|
||||
&& peer.name.trim().to_lowercase() == identity
|
||||
})
|
||||
});
|
||||
if local_collision || population_collision {
|
||||
return Err(KernelError::Invalid(
|
||||
"reaction identity adoption conflicts with an established local or population identity"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_witnessed_turn<'a>(
|
||||
campaign: &'a Campaign,
|
||||
event_summary: &str,
|
||||
|
|
@ -6159,6 +6198,22 @@ mod tests {
|
|||
memories: vec![],
|
||||
},
|
||||
);
|
||||
seed.actors.insert(
|
||||
"taren".into(),
|
||||
ActorState {
|
||||
id: "taren".into(),
|
||||
name: "Taren".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![],
|
||||
},
|
||||
);
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
|
|
@ -6206,6 +6261,25 @@ mod tests {
|
|||
assert!(
|
||||
matches!(identity_result, Err(KernelError::Invalid(message)) if message.contains("exact spoken handle"))
|
||||
);
|
||||
let conflicting_identity_result = kernel
|
||||
.command(WorldCommand::ResolveReactionWave {
|
||||
expected_revision: 0,
|
||||
event_summary: "player says: Tell me which seal I repaired.".into(),
|
||||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: Some("My name is Taren.".into()),
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta {
|
||||
identity_adoption: Some("Taren".into()),
|
||||
..Default::default()
|
||||
},
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
matches!(conflicting_identity_result, Err(KernelError::Invalid(message)) if message.contains("established local or population identity"))
|
||||
);
|
||||
let persisted = store
|
||||
.load::<Campaign>("campaign.v1", &seed.id.to_string())
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ pub struct PermittedActorSlice {
|
|||
pub snapshot_binding: String,
|
||||
pub interaction_role: ActorInteractionRole,
|
||||
pub identity_experience: Vec<String>,
|
||||
/// Established identifiers owned by other people in the actor's immediate
|
||||
/// scene or source population. This is social context, not a namespace
|
||||
/// registry: the kernel uses it only to prevent a newly adopted handle
|
||||
/// from silently impersonating an already-durable person.
|
||||
pub reserved_public_identities: BTreeSet<String>,
|
||||
pub memories: Vec<String>,
|
||||
pub perceived_events: Vec<String>,
|
||||
pub perceived_actors: std::collections::BTreeMap<String, String>,
|
||||
|
|
@ -511,8 +516,22 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S
|
|||
"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."
|
||||
}
|
||||
};
|
||||
let established_peer_identities = if slice.reserved_public_identities.is_empty() {
|
||||
"no other established public self-identifiers are active in your immediate social context"
|
||||
.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"these public self-identifiers already belong to other durable people in your immediate social context: {}",
|
||||
slice
|
||||
.reserved_public_identities
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{projection}\n\nYour active self-identity: {identity}. {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\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."
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -556,6 +575,15 @@ fn validate_actor_proposals(
|
|||
"Persona identity adoption must copy an exact spoken handle"
|
||||
));
|
||||
}
|
||||
if slice
|
||||
.reserved_public_identities
|
||||
.iter()
|
||||
.any(|reserved| reserved.trim().to_lowercase() == identity.to_lowercase())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Persona identity adoption conflicts with an established peer identity"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -566,7 +594,7 @@ fn append_actor_correction(
|
|||
rejected: &serde_json::Value,
|
||||
) {
|
||||
request.lived_stream.push_str(&format!(
|
||||
"\n\nCORRECTION TASK—THE PREVIOUS INTERPRETATION WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_INTERPRETATION:\n{}\nReturn one corrected complete interpretation against the same snapshot, lived stream, Persona output, and exact permissions. Preserve supported speech and private changes. Do not invent speech to justify a delta. If the Persona did not explicitly adopt or present a public self-identifier in its own speech, identity_adoption must be null.",
|
||||
"\n\nCORRECTION TASK—THE PREVIOUS INTERPRETATION WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_INTERPRETATION:\n{}\nReturn one corrected complete interpretation against the same snapshot, lived stream, Persona output, and exact permissions. Preserve supported speech and private changes. Do not invent speech to justify a delta. If the Persona did not explicitly adopt or present a public self-identifier in its own speech, identity_adoption must be null. If the Persona explicitly claimed an established peer identity, do not hide the conflict by nulling identity_adoption; preserve the extracted claim so the unchanged Persona turn remains rejected.",
|
||||
serde_json::to_string(rejected).unwrap_or_else(|_| "null".into())
|
||||
));
|
||||
}
|
||||
|
|
@ -4182,6 +4210,7 @@ mod tests {
|
|||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::PresentObserver,
|
||||
identity_experience: vec!["A tired navigator".into()],
|
||||
reserved_public_identities: BTreeSet::new(),
|
||||
memories: vec!["Proposed the eastern trail evacuation at dusk.".into()],
|
||||
perceived_events: vec![],
|
||||
perceived_actors: std::collections::BTreeMap::from([(
|
||||
|
|
@ -4211,12 +4240,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn persona_identity_adoption_must_be_spoken_exactly() {
|
||||
let slice = PermittedActorSlice {
|
||||
let mut slice = PermittedActorSlice {
|
||||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::DirectResponseExpected,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
reserved_public_identities: BTreeSet::new(),
|
||||
memories: vec![],
|
||||
perceived_events: vec!["The player asks your name.".into()],
|
||||
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
|
||||
|
|
@ -4246,6 +4276,13 @@ mod tests {
|
|||
);
|
||||
proposals.speech = Some("My name is Taren.".into());
|
||||
validate_actor_proposals(&slice, &proposals).unwrap();
|
||||
slice.reserved_public_identities.insert(" tArEn ".into());
|
||||
assert!(
|
||||
validate_actor_proposals(&slice, &proposals)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("established peer identity")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4256,6 +4293,7 @@ mod tests {
|
|||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::PresentObserver,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
reserved_public_identities: BTreeSet::new(),
|
||||
memories: vec![],
|
||||
perceived_events: vec!["A regulator is inspected.".into()],
|
||||
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
|
||||
|
|
@ -4349,6 +4387,7 @@ mod tests {
|
|||
snapshot_binding: "campaign:1:revision:4".into(),
|
||||
interaction_role: ActorInteractionRole::DirectResponseExpected,
|
||||
identity_experience: vec!["You are an unnamed patient.".into()],
|
||||
reserved_public_identities: BTreeSet::new(),
|
||||
memories: vec![],
|
||||
perceived_events: vec!["The player asks your name.".into()],
|
||||
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ pub async fn appraise_present(
|
|||
let event = event_summary.to_owned();
|
||||
let receipts = campaign.branch_origin.evidence_receipt_ids.clone();
|
||||
let perceived_actors = perceived_actors.clone();
|
||||
let reserved_public_identities = reserved_public_identities(campaign, &actor);
|
||||
let semaphore = semaphore.clone();
|
||||
let interaction_role = if response_expected_actor_ids.contains(&actor.id) {
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
|
|
@ -234,6 +235,7 @@ pub async fn appraise_present(
|
|||
snapshot_binding: snapshot,
|
||||
interaction_role,
|
||||
identity_experience: vec![format!("You are {}.", actor.name)],
|
||||
reserved_public_identities,
|
||||
memories: actor.memories,
|
||||
perceived_events: vec![event],
|
||||
perceived_actors,
|
||||
|
|
@ -278,6 +280,34 @@ pub async fn appraise_present(
|
|||
})
|
||||
}
|
||||
|
||||
fn reserved_public_identities(
|
||||
campaign: &Campaign,
|
||||
actor: &crate::domain::ActorState,
|
||||
) -> BTreeSet<String> {
|
||||
let own_identity = actor.name.trim().to_lowercase();
|
||||
let mut identities = campaign
|
||||
.actors
|
||||
.values()
|
||||
.filter(|other| other.id != actor.id && other.location_id == actor.location_id)
|
||||
.map(|other| other.name.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if let Some(member) = campaign
|
||||
.gestalt_members
|
||||
.values()
|
||||
.find(|member| member.materialized_actor_id.as_deref() == Some(actor.id.as_str()))
|
||||
{
|
||||
identities.extend(
|
||||
campaign
|
||||
.gestalt_members
|
||||
.values()
|
||||
.filter(|peer| peer.id != member.id && peer.gestalt_id == member.gestalt_id)
|
||||
.map(|peer| peer.name.clone()),
|
||||
);
|
||||
}
|
||||
identities.retain(|identity| identity.trim().to_lowercase() != own_identity);
|
||||
identities
|
||||
}
|
||||
|
||||
fn canonical_reaction_turn<'a>(
|
||||
campaign: &'a Campaign,
|
||||
event_summary: &str,
|
||||
|
|
@ -302,7 +332,7 @@ fn canonical_reaction_turn<'a>(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
domain::{ActorState, NarrativeTurn},
|
||||
domain::{ActorState, GestaltMemberDelta, NarrativeTurn},
|
||||
model::ModelStageRequest,
|
||||
persona::{AllowAllPermit, PersonaProjectionEngine},
|
||||
};
|
||||
|
|
@ -400,6 +430,57 @@ mod tests {
|
|||
campaign
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialized_actor_receives_dormant_peer_identities_as_social_context() {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
let location = campaign.actors[&campaign.player_actor_id]
|
||||
.location_id
|
||||
.clone();
|
||||
campaign.actors.insert(
|
||||
"member:oxygen-patient".into(),
|
||||
actor("member:oxygen-patient", "Oxygen Patient", &location),
|
||||
);
|
||||
let member =
|
||||
|id: &str, name: &str, materialized_actor_id: Option<&str>| GestaltMemberDelta {
|
||||
schema: "ghostlight.gestalt_member_delta.v1".into(),
|
||||
id: id.into(),
|
||||
gestalt_id: "refugees".into(),
|
||||
version: 0,
|
||||
name: name.into(),
|
||||
capability_additions: BTreeSet::new(),
|
||||
capability_removals: BTreeSet::new(),
|
||||
knowledge_additions: BTreeSet::new(),
|
||||
knowledge_removals: BTreeSet::new(),
|
||||
equipment: BTreeSet::new(),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec![],
|
||||
memories: vec![],
|
||||
last_location_id: Some(location.clone()),
|
||||
materialized_actor_id: materialized_actor_id.map(str::to_owned),
|
||||
last_relevant_revision: 0,
|
||||
relevance_lease_until_revision: 0,
|
||||
};
|
||||
campaign.gestalt_members.insert(
|
||||
"oxygen-patient".into(),
|
||||
member(
|
||||
"oxygen-patient",
|
||||
"Oxygen Patient",
|
||||
Some("member:oxygen-patient"),
|
||||
),
|
||||
);
|
||||
campaign
|
||||
.gestalt_members
|
||||
.insert("taren".into(), member("taren", "Taren", None));
|
||||
|
||||
let reserved =
|
||||
reserved_public_identities(&campaign, &campaign.actors["member:oxygen-patient"]);
|
||||
|
||||
assert!(reserved.contains("Taren"));
|
||||
assert!(!reserved.contains("Oxygen Patient"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn address_resolver_selects_exact_supplied_respondents() {
|
||||
let campaign = addressed_campaign();
|
||||
|
|
|
|||
|
|
@ -292,6 +292,9 @@ revisioned commands before the participant appraisal wave. Automatic plans
|
|||
cannot write aggregate gestalt learning. A malformed, conflicting, stale, or
|
||||
partially invalid plan changes nothing. The compiler may seed likely members,
|
||||
but it is not required to predict every future person at campaign creation.
|
||||
Identity uniqueness is scoped to the active local population rather than the
|
||||
whole universe: a new member cannot reuse the normalized public identifier of
|
||||
an existing local actor or a durable member of a locally active Gestalt.
|
||||
|
||||
First-relevance identity admission is bound to the exact immediately committed
|
||||
player-speech turn. Existing dormant members may be recast after any relevant
|
||||
|
|
@ -783,6 +786,15 @@ Both Interpreter receipts survive, with the rejected one marked
|
|||
`semantic_invalid`; a second failure aborts the complete reaction wave without
|
||||
private-state mutation.
|
||||
|
||||
For a materialized Gestalt member, the Projector slice also carries the public
|
||||
self-identifiers already owned by other people in the same active population,
|
||||
including dormant member deltas. The lived stream presents these as social
|
||||
knowledge, not raw state. The Interpreter rejects a newly adopted handle that
|
||||
collides with that bounded roster, and `WorldKernel` independently rederives
|
||||
the same local/population conflict before admitting the identity mutation.
|
||||
Existing names are grandfathered: this gate prevents a new identity claim and
|
||||
does not silently rename or merge durable people.
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue