From d77f94f49c249963b9a92f89ebf39fba0aab89c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 21:40:59 +0200 Subject: [PATCH] Preserve materialized Gestalt actor authority --- crates/ghostlight-dungeon/src/kernel.rs | 17 +- crates/ghostlight-dungeon/src/outcome.rs | 162 ++++++++++++++++---- crates/ghostlight-dungeon/src/resolution.rs | 151 ++++++++++++++++-- crates/ghostlight-dungeon/src/scheduler.rs | 100 ++++++++++-- 4 files changed, 368 insertions(+), 62 deletions(-) diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 5af58f2..296e7c8 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -2568,13 +2568,6 @@ fn strategic_activity_summary( } fn agency_subject_name(campaign: &Campaign, subject_id: &str) -> Result { - 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 Result { - 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 Result> { - 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> { - 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> { - 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 Result { - 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 { .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(); diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index d306edb..95ab0c0 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -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( + 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] { diff --git a/crates/ghostlight-dungeon/src/scheduler.rs b/crates/ghostlight-dungeon/src/scheduler.rs index de9a2d2..0e962fa 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -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::*;