Preserve materialized Gestalt actor authority

This commit is contained in:
Meta Vens 2026-08-23 21:40:59 +02:00
commit d77f94f49c
4 changed files with 368 additions and 62 deletions

View file

@ -2568,13 +2568,6 @@ fn strategic_activity_summary(
}
fn agency_subject_name(campaign: &Campaign, subject_id: &str) -> Result<String, KernelError> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
return campaign
.gestalt_members
.get(member_id)
.map(|value| value.name.clone())
.ok_or_else(|| KernelError::Invalid("strategic activity target vanished".into()));
}
campaign
.actors
.get(subject_id)
@ -2591,6 +2584,16 @@ fn agency_subject_name(campaign: &Campaign, subject_id: &str) -> Result<String,
.get(subject_id)
.map(|value| value.name.clone())
})
.or_else(|| {
crate::resolution::dormant_member_id_for_subject(campaign, subject_id).and_then(
|member_id| {
campaign
.gestalt_members
.get(member_id)
.map(|value| value.name.clone())
},
)
})
.ok_or_else(|| KernelError::Invalid("strategic activity target vanished".into()))
}

View file

@ -1165,10 +1165,11 @@ fn action_context(
resource_owner_id: proposal.subject_id.clone(),
resource_recipient_ids,
discoverable_facts,
member_state_owner_id: proposal
.subject_id
.strip_prefix("member:")
.map(str::to_owned),
member_state_owner_id: crate::resolution::dormant_member_id_for_subject(
campaign,
&proposal.subject_id,
)
.map(str::to_owned),
admissible_effect_kinds: admissible_effect_kinds(campaign, proposal)?,
allowed_state_references: allowed_state_references(campaign, proposal)?
.into_iter()
@ -1215,7 +1216,7 @@ fn admissible_effect_kinds(
if !pressure_owner_ids(campaign, proposal)?.is_empty() {
kinds.push(OutcomeEffectKind::GestaltPressure);
}
if !source.starts_with("member:")
if crate::resolution::dormant_member_id_for_subject(campaign, source).is_none()
&& matches!(
activity,
StrategicActivityKind::Coordinate
@ -1234,7 +1235,7 @@ fn admissible_effect_kinds(
{
kinds.push(OutcomeEffectKind::AgencyRelationShift);
}
if let Some(member_id) = source.strip_prefix("member:") {
if let Some(member_id) = crate::resolution::dormant_member_id_for_subject(campaign, source) {
let member = &campaign.gestalt_members[member_id];
if member.memories.len() < 64 {
kinds.push(OutcomeEffectKind::MemberMemory);
@ -1265,7 +1266,16 @@ fn admissible_effect_kinds(
}
fn subject_summary(campaign: &Campaign, subject_id: &str) -> Result<serde_json::Value> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
if let Some(actor) = campaign.actors.get(subject_id) {
return Ok(serde_json::json!({
"subject_id":subject_id,"name":actor.name,"kind":"actor",
"capabilities":actor.capabilities,"knowledge":actor.knowledge,
"resources":actor.equipment,"conditions":actor.conditions,
"obligations":actor.obligations,"relationships":actor.relationships,"goals":actor.goals,
}));
}
if let Some(member_id) = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)
{
let member = campaign
.gestalt_members
.get(member_id)
@ -1297,14 +1307,6 @@ fn subject_summary(campaign: &Campaign, subject_id: &str) -> Result<serde_json::
"resources":institution.resources,"goals":institution.goals,"posture":institution.posture,
}));
}
if let Some(actor) = campaign.actors.get(subject_id) {
return Ok(serde_json::json!({
"subject_id":subject_id,"name":actor.name,"kind":"actor",
"capabilities":actor.capabilities,"knowledge":actor.knowledge,
"resources":actor.equipment,"conditions":actor.conditions,
"obligations":actor.obligations,"relationships":actor.relationships,"goals":actor.goals,
}));
}
Err(anyhow!("outcome context subject vanished"))
}
@ -1312,7 +1314,9 @@ fn allowed_state_references(
campaign: &Campaign,
proposal: &CellActionProposal,
) -> Result<BTreeSet<String>> {
let mut references = if let Some(member_id) = proposal.subject_id.strip_prefix("member:") {
let mut references = if let Some(member_id) =
crate::resolution::dormant_member_id_for_subject(campaign, &proposal.subject_id)
{
crate::resolution::member_state_references(campaign, member_id)?
} else {
subject_state_references(campaign, &proposal.subject_id)?
@ -1396,20 +1400,25 @@ fn discoverable_fact_ids(
}
fn source_knowledge(campaign: &Campaign, subject_id: &str) -> Result<BTreeSet<String>> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
return effective_member_knowledge(campaign, member_id);
}
if let Some(gestalt) = campaign.gestalts.get(subject_id) {
return Ok(gestalt.shared_knowledge.clone());
}
if let Some(actor) = campaign.actors.get(subject_id) {
return Ok(actor.knowledge.clone());
}
if let Some(member_id) = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)
{
return effective_member_knowledge(campaign, member_id);
}
Ok(BTreeSet::new())
}
pub fn subject_resources(campaign: &Campaign, subject_id: &str) -> Result<BTreeSet<String>> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
if let Some(actor) = campaign.actors.get(subject_id) {
return Ok(actor.equipment.clone());
}
if let Some(member_id) = crate::resolution::dormant_member_id_for_subject(campaign, subject_id)
{
return campaign
.gestalt_members
.get(member_id)
@ -1422,9 +1431,6 @@ pub fn subject_resources(campaign: &Campaign, subject_id: &str) -> Result<BTreeS
if let Some(institution) = campaign.institutions.get(subject_id) {
return Ok(institution.resources.iter().cloned().collect());
}
if let Some(actor) = campaign.actors.get(subject_id) {
return Ok(actor.equipment.clone());
}
Err(anyhow!("resource owner is unknown"))
}
@ -1478,13 +1484,6 @@ fn activity_parts(
}
fn subject_name(campaign: &Campaign, subject_id: &str) -> Result<String> {
if let Some(member_id) = subject_id.strip_prefix("member:") {
return campaign
.gestalt_members
.get(member_id)
.map(|member| member.name.clone())
.ok_or_else(|| anyhow!("outcome source member vanished"));
}
campaign
.actors
.get(subject_id)
@ -1501,6 +1500,16 @@ fn subject_name(campaign: &Campaign, subject_id: &str) -> Result<String> {
.get(subject_id)
.map(|value| value.name.clone())
})
.or_else(|| {
crate::resolution::dormant_member_id_for_subject(campaign, subject_id).and_then(
|member_id| {
campaign
.gestalt_members
.get(member_id)
.map(|value| value.name.clone())
},
)
})
.ok_or_else(|| anyhow!("outcome source vanished"))
}
@ -1511,8 +1520,7 @@ fn outcome_source_receipts(campaign: &Campaign, proposal: &CellActionProposal) -
subjects.extend(targets);
}
for subject in subjects {
let profile_id = subject
.strip_prefix("member:")
let profile_id = crate::resolution::dormant_member_id_for_subject(campaign, &subject)
.and_then(|member_id| campaign.gestalt_members.get(member_id))
.map(|member| member.gestalt_id.as_str())
.unwrap_or(&subject);
@ -2306,6 +2314,96 @@ mod tests {
}
}
#[test]
fn materialized_member_outcome_context_uses_actor_state_and_effects() {
let mut value = campaign();
value.gestalt_members.insert(
"sable".into(),
crate::domain::GestaltMemberDelta {
schema: "ghostlight.gestalt_member_delta.v1".into(),
id: "sable".into(),
gestalt_id: "dockers".into(),
version: 1,
name: "Sable".into(),
capability_additions: BTreeSet::new(),
capability_removals: BTreeSet::new(),
knowledge_additions: BTreeSet::new(),
knowledge_removals: BTreeSet::new(),
equipment: BTreeSet::from(["folded kit".into()]),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
last_location_id: Some("dock".into()),
materialized_actor_id: Some("member:sable".into()),
last_relevant_revision: 4,
relevance_lease_until_revision: 9,
},
);
value.actors.insert(
"member:sable".into(),
crate::domain::ActorState {
id: "member:sable".into(),
name: "Sable".into(),
location_id: "dock".into(),
capabilities: BTreeSet::from(["route scouting".into()]),
knowledge: BTreeSet::from(["the east gate is watched".into()]),
equipment: BTreeSet::from(["materialized kit".into()]),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
},
);
ensure_agency_profiles(&mut value);
let action = CellActionProposal {
subject_id: "member:sable".into(),
intent: "inspect the eastern trail".into(),
intended_effect: "identify an immediate route hazard".into(),
priority: 50,
state_references: vec![
"subject:member:sable".into(),
"location:dock".into(),
"capability:route scouting".into(),
],
public_channels: vec![],
effect: StrategicCellEffect::ActorActivity {
actor_id: "member:sable".into(),
activity: StrategicActivityKind::Investigate,
target_subject_ids: vec![],
location_ids: vec!["dock".into()],
},
};
let context = build_context(&value, &[action]).unwrap();
let action = &context.actions[0];
assert_eq!(action.source_state["kind"], "actor");
assert_eq!(action.source_state["resources"][0], "materialized kit");
assert_eq!(action.member_state_owner_id, None);
assert!(
action
.allowed_state_references
.contains(&"subject:member:sable".into())
);
assert!(
!action
.admissible_effect_kinds
.contains(&OutcomeEffectKind::MemberMemory)
);
assert!(
!action
.admissible_effect_kinds
.contains(&OutcomeEffectKind::MemberObligation)
);
assert!(
!action
.admissible_effect_kinds
.contains(&OutcomeEffectKind::MemberRelationship)
);
}
#[test]
fn knowledge_outcome_requires_exact_discoverable_fact() {
let value = campaign();

View file

@ -1848,10 +1848,10 @@ fn validate_cell_proposal(
{
return Err(anyhow!("cell proposal has no exact constituent authority"));
}
if let Some(member_id) = proposal.subject_id.strip_prefix("member:") {
return validate_member_cell_proposal(campaign, cell, member_id, proposal);
}
if !cell.subject_ids.contains(&proposal.subject_id) {
if let Some(member_id) = dormant_member_id_for_subject(campaign, &proposal.subject_id) {
return validate_member_cell_proposal(campaign, cell, member_id, proposal);
}
return Err(anyhow!("cell proposal has no exact constituent authority"));
}
let profile = campaign
@ -2026,13 +2026,15 @@ fn cell_contains_attributed_subject(
if is_human_controlled_actor(campaign, subject_id) {
return false;
}
if let Some(member_id) = subject_id.strip_prefix("member:") {
return campaign
.gestalt_members
.get(member_id)
.is_some_and(|member| cell.subject_ids.contains(&member.gestalt_id));
if cell.subject_ids.contains(subject_id) {
return true;
}
cell.subject_ids.contains(subject_id)
if let Some(member_id) = dormant_member_id_for_subject(campaign, subject_id) {
return cell
.subject_ids
.contains(&campaign.gestalt_members[member_id].gestalt_id);
}
false
}
fn is_human_controlled_actor(campaign: &Campaign, subject_id: &str) -> bool {
@ -2625,6 +2627,23 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<B
Ok(targets)
}
/// Resolve a subject-shaped member ID only while its canonical person remains
/// folded into a Gestalt. Materialized people deliberately retain the same
/// stable ID, so the `member:` prefix cannot own their current subject kind.
pub fn dormant_member_id_for_subject<'a>(
campaign: &'a Campaign,
subject_id: &'a str,
) -> Option<&'a str> {
let member_id = subject_id.strip_prefix("member:")?;
campaign
.gestalt_members
.get(member_id)
.filter(|member| {
member.materialized_actor_id.is_none() && !campaign.actors.contains_key(subject_id)
})
.map(|_| member_id)
}
pub fn validate_active_gestalt_presence_location(
campaign: &Campaign,
gestalt_id: &str,
@ -2978,6 +2997,120 @@ pub(crate) mod tests {
);
}
#[test]
fn materialized_member_actor_has_one_exact_cell_authority() {
let mut value = campaign(0, 2);
value.gestalts.insert(
"refugees".into(),
GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: "refugees".into(),
name: "Refugees".into(),
version: 0,
home_location_id: "center".into(),
shared_capabilities: BTreeSet::new(),
shared_knowledge: BTreeSet::new(),
resources: BTreeSet::new(),
goals: vec![],
pressures: vec![],
},
);
value.gestalt_members.insert(
"sable".into(),
GestaltMemberDelta {
schema: "ghostlight.gestalt_member_delta.v1".into(),
id: "sable".into(),
gestalt_id: "refugees".into(),
version: 1,
name: "Sable".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("center".into()),
materialized_actor_id: Some("member:sable".into()),
last_relevant_revision: 0,
relevance_lease_until_revision: 5,
},
);
value.actors.insert(
"member:sable".into(),
ActorState {
id: "member:sable".into(),
name: "Sable".into(),
location_id: "center".into(),
capabilities: BTreeSet::from(["route scouting".into()]),
knowledge: BTreeSet::new(),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
},
);
ensure_agency_profiles(&mut value);
let actor_cell = SimulationCell {
schema: "ghostlight.simulation_cell.v1".into(),
id: "actor-cell".into(),
mode: SimulationCellMode::Arena,
subject_ids: BTreeSet::from(["member:sable".into()]),
merge_loss: MergeLoss::default(),
rationale: "exact materialized actor".into(),
lease_until_world_revision: 0,
lease_until_strategic_tick: 0,
detail_focus_subject_id: None,
};
let population_cell = SimulationCell {
schema: "ghostlight.simulation_cell.v1".into(),
id: "population-cell".into(),
mode: SimulationCellMode::Cohesive,
subject_ids: BTreeSet::from(["refugees".into()]),
merge_loss: MergeLoss::default(),
rationale: "population baseline".into(),
lease_until_world_revision: 0,
lease_until_strategic_tick: 0,
detail_focus_subject_id: None,
};
let proposal = CellActionProposal {
subject_id: "member:sable".into(),
intent: "inspect the eastern trail".into(),
intended_effect: "identify an immediate route hazard".into(),
priority: 50,
state_references: vec![
"subject:member:sable".into(),
"location:center".into(),
"capability:route scouting".into(),
],
public_channels: vec![],
effect: StrategicCellEffect::ActorActivity {
actor_id: "member:sable".into(),
activity: StrategicActivityKind::Investigate,
target_subject_ids: vec![],
location_ids: vec!["center".into()],
},
};
validate_cell_proposal(&value, &actor_cell, &proposal).unwrap();
assert!(cell_contains_attributed_subject(
&value,
&actor_cell,
"member:sable"
));
assert!(!cell_contains_attributed_subject(
&value,
&population_cell,
"member:sable"
));
assert_eq!(dormant_member_id_for_subject(&value, "member:sable"), None);
}
#[test]
fn aetheria_scale_cover_is_complete_unique_and_budgeted() {
for budget in [1, 4, 8, 32] {

View file

@ -837,20 +837,7 @@ fn activity_target_slices(
.iter()
.map(|target_id| {
let (name, location_ids) =
if let Some(member_id) = target_id.strip_prefix("member:") {
let member = campaign.gestalt_members.get(member_id).ok_or_else(|| {
anyhow!("activity target {target_id} has no member state")
})?;
(
member.name.clone(),
BTreeSet::from([crate::resolution::dormant_member_location(
campaign, member_id,
)?]),
)
} else {
let profile = campaign.agency_profiles.get(target_id).ok_or_else(|| {
anyhow!("activity target {target_id} has no agency profile")
})?;
if let Some(profile) = campaign.agency_profiles.get(target_id) {
let name = campaign
.actors
.get(target_id)
@ -871,6 +858,20 @@ fn activity_target_slices(
anyhow!("activity target {target_id} has no canonical named subject")
})?;
(name, profile.location_ids.clone())
} else if let Some(member_id) =
crate::resolution::dormant_member_id_for_subject(campaign, target_id)
{
let member = campaign.gestalt_members.get(member_id).ok_or_else(|| {
anyhow!("activity target {target_id} has no member state")
})?;
(
member.name.clone(),
BTreeSet::from([crate::resolution::dormant_member_location(
campaign, member_id,
)?]),
)
} else {
return Err(anyhow!("activity target {target_id} has no agency profile"));
};
let locations = location_ids
.into_iter()
@ -1182,6 +1183,77 @@ mod tests {
assert!(!reed.locations.contains_key("garrison"));
}
#[test]
fn materialized_gestalt_member_is_projected_as_an_actor_target() {
use crate::domain::{ActorState, GestaltMemberDelta, GestaltPersonaState};
let mut campaign = crate::resolution::tests::campaign(0, 2);
campaign.gestalts.insert(
"refugees".into(),
GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: "refugees".into(),
name: "Refugees".into(),
version: 0,
home_location_id: "center".into(),
shared_capabilities: BTreeSet::new(),
shared_knowledge: BTreeSet::new(),
resources: BTreeSet::new(),
goals: vec![],
pressures: vec![],
},
);
campaign.gestalt_members.insert(
"sable".into(),
GestaltMemberDelta {
schema: "ghostlight.gestalt_member_delta.v1".into(),
id: "sable".into(),
gestalt_id: "refugees".into(),
version: 1,
name: "Sable".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("center".into()),
materialized_actor_id: Some("member:sable".into()),
last_relevant_revision: 0,
relevance_lease_until_revision: 5,
},
);
campaign.actors.insert(
"member:sable".into(),
ActorState {
id: "member:sable".into(),
name: "Sable".into(),
location_id: "center".into(),
capabilities: BTreeSet::from(["route scouting".into()]),
knowledge: BTreeSet::new(),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec![],
memories: vec![],
},
);
crate::resolution::ensure_agency_profiles(&mut campaign);
let slice = constituent_slice(&campaign, "player").unwrap();
let sable = slice.activity_targets.get("member:sable").unwrap();
assert_eq!(sable.name, "Sable");
assert_eq!(
sable.locations.get("center").map(String::as_str),
Some("Center")
);
}
#[test]
fn cell_slice_projects_only_actionable_salient_member_exceptions() {
use crate::domain::*;