diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs index 5c6ef87..d2edacd 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -778,7 +778,7 @@ fn member_outcome_kind( ) -> Option<&'static str> { use ghostlight_dungeon::domain::StrategicOutcomeEffect; - let member_subject_id = format!("member:{member_id}"); + let member_subject_id = ghostlight_dungeon::domain::gestalt_member_subject_id(member_id); match effect { StrategicOutcomeEffect::MemberMemory { member_id: owner, .. diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index ba42cfa..a8e075b 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -183,6 +183,9 @@ struct CompiledLocation { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] struct CompiledGestaltMemberDelta { schema: String, + #[schemars( + description = "Local stable member ID without the world-subject `member:` namespace prefix." + )] id: String, gestalt_id: String, version: u64, @@ -2505,7 +2508,7 @@ fn validate_shared_seed_excludes_locally_owned_subjects( seed.gestalt_members .iter() .filter(|member| locally_owned_names.contains(&normalized_identity(&member.name))) - .map(|member| format!("member:{}", member.id)), + .map(|member| crate::domain::gestalt_member_subject_id(&member.id)), ) .collect::>(); if collisions.is_empty() { @@ -2723,7 +2726,7 @@ fn canonical_relationship_subject_ids(campaign: &Campaign) -> BTreeSet { campaign .gestalt_members .keys() - .map(|member_id| format!("member:{member_id}")), + .map(|member_id| crate::domain::gestalt_member_subject_id(member_id)), ); targets } @@ -3845,7 +3848,13 @@ fn ensure_distinct_fields( } } -fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result { +fn seed_to_campaign(mut seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result { + for member in &mut seed.gestalt_members { + member.id = crate::domain::canonical_gestalt_member_local_id(&member.id); + if member.id.is_empty() { + return Err(anyhow!("compiled gestalt member ID is empty")); + } + } require_unique_ids( "location", seed.locations.iter().map(|item| item.id.as_str()), @@ -4340,7 +4349,16 @@ pub fn validate_campaign_seed(c: &Campaign) -> Result<()> { )); } } - for member in c.gestalt_members.values() { + for (member_key, member) in &c.gestalt_members { + if member_key != &member.id + || member.id.is_empty() + || crate::domain::canonical_gestalt_member_local_id(&member.id) != member.id + { + return Err(anyhow!( + "gestalt member {} must use one canonical local ID without a member: prefix", + member.id + )); + } if !c.gestalts.contains_key(&member.gestalt_id) { return Err(anyhow!( "gestalt member {} references unknown gestalt {}", @@ -5190,7 +5208,7 @@ mod tests { "locations":[{"id":"yard","name":"Yard","container_id":null,"routes":[{"route_id":"out","destination_id":destination,"distance":"near","travel_minutes":5}],"persistent_features":["same yard"]}], "actors":[], "gestalts":[{"schema":"ghostlight.gestalt_persona_state.v1","id":"yard-workers","name":"Yard workers","version":0,"home_location_id":"yard","shared_capabilities":["maintain machinery"],"shared_knowledge":["yard routines"],"resources":["tool shed"],"goals":["finish the shift"],"pressures":["the gate is failing"]}], - "gestalt_members":[{"schema":"ghostlight.gestalt_member_delta.v1","id":"john","gestalt_id":"yard-workers","version":0,"name":"John the smith","capability_additions":["forge hinges"],"capability_removals":[],"knowledge_additions":[],"knowledge_removals":[],"equipment":["hammer"],"conditions":[],"obligations":[],"relationships":[],"goals":[],"memories":[],"last_location_id":"yard","materialized_actor_id":null}], + "gestalt_members":[{"schema":"ghostlight.gestalt_member_delta.v1","id":"member:john","gestalt_id":"yard-workers","version":0,"name":"John the smith","capability_additions":["forge hinges"],"capability_removals":[],"knowledge_additions":[],"knowledge_removals":[],"equipment":["hammer"],"conditions":[],"obligations":[],"relationships":[],"goals":[],"memories":[],"last_location_id":"yard","materialized_actor_id":null}], "institutions":[],"clocks":[{"id":"shift","label":"Shift ends","progress":0,"threshold":4,"consequence":"night"}], "facts":[ {"id":"f","statement":"A witnessed fact","scope":"canon_baseline","evidence_receipt_ids":["fixture"]}, diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 0a4c5ef..7cd5be5 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -158,6 +158,22 @@ pub struct GestaltMemberDelta { pub relevance_lease_until_revision: u64, } +/// Gestalt member records own a local identity. The `member:` namespace is a +/// world-subject projection and must never become part of that stored local ID. +/// Repeated prefixes are accepted only at model and legacy-state boundaries so +/// every downstream organ converges on one canonical person. +pub fn canonical_gestalt_member_local_id(value: &str) -> String { + let mut value = value.trim(); + while let Some(unprefixed) = value.strip_prefix("member:") { + value = unprefixed; + } + value.to_owned() +} + +pub fn gestalt_member_subject_id(member_id: &str) -> String { + format!("member:{}", canonical_gestalt_member_local_id(member_id)) +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct GestaltPromotion { pub gestalt_id: String, diff --git a/crates/ghostlight-dungeon/src/gestalt.rs b/crates/ghostlight-dungeon/src/gestalt.rs index c83ba17..7eb3e05 100644 --- a/crates/ghostlight-dungeon/src/gestalt.rs +++ b/crates/ghostlight-dungeon/src/gestalt.rs @@ -49,7 +49,7 @@ impl GestaltPresencePlanner { "member": member, "player_relationship_to_member": player .relationships - .get(&format!("member:{}", member.id)), + .get(&crate::domain::gestalt_member_subject_id(&member.id)), }) }) .collect::>(); diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index abc6fd5..53fb1a3 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1688,7 +1688,8 @@ fn apply_individuation( campaign: &mut Campaign, individuation: &GestaltIndividuation, ) -> Result<(), KernelError> { - let member = &individuation.member; + let mut member = individuation.member.clone(); + member.id = crate::domain::canonical_gestalt_member_local_id(&member.id); let gestalt = campaign .gestalts .get(&individuation.gestalt_id) @@ -1773,7 +1774,7 @@ fn apply_promotion( "gestalt member is already materialized".into(), )); } - let actor_id = format!("member:{}", member.id); + let actor_id = crate::domain::gestalt_member_subject_id(&member.id); if campaign.actors.contains_key(&actor_id) { return Err(KernelError::Invalid( "materialized actor id collides".into(), @@ -2267,7 +2268,7 @@ fn apply_strategic_tick_plan( merge_strategic_outcome_event_context( &mut outcome_event_context, &activity.action_digest, - &format!("member:{}", activity.member_id), + &crate::domain::gestalt_member_subject_id(&activity.member_id), &activity.location_ids, &activity.public_channels, )?; @@ -2742,7 +2743,7 @@ fn apply_strategic_tick_plan( .filter(|target| campaign.institutions.contains_key(*target)) .cloned() .collect(); - let mut actor_ids = vec![format!("member:{}", action.member_id)]; + let mut actor_ids = vec![crate::domain::gestalt_member_subject_id(&action.member_id)]; actor_ids.extend( action .target_subject_ids @@ -2772,7 +2773,7 @@ fn apply_strategic_tick_plan( &prospective_actor_locations, &prospective_gestalt_locations, &prospective_member_locations, - &format!("member:{}", action.member_id), + &crate::domain::gestalt_member_subject_id(&action.member_id), &action.location_ids, )?; let event = Event { @@ -2828,7 +2829,7 @@ fn apply_strategic_tick_plan( "{member_name} moves from {origin} to {} and joins {}.", action.destination_location_id, action.destination_gestalt_id ), - actor_ids: vec![format!("member:{}", action.member_id)], + actor_ids: vec![crate::domain::gestalt_member_subject_id(&action.member_id)], institution_ids: vec![], gestalt_ids: vec![action.source_gestalt_id, action.destination_gestalt_id], location_ids: vec![origin, action.destination_location_id], @@ -2949,7 +2950,7 @@ fn collect_outcome_subject_ids( StrategicOutcomeEffect::MemberMemory { member_id, .. } | StrategicOutcomeEffect::MemberObligation { member_id, .. } | StrategicOutcomeEffect::MemberRelationship { member_id, .. } => { - subjects.insert(format!("member:{member_id}")); + subjects.insert(crate::domain::gestalt_member_subject_id(member_id)); if let StrategicOutcomeEffect::MemberRelationship { other_subject_id, .. } = effect @@ -3662,7 +3663,7 @@ mod tests { .chain(plan.member_activities.iter().map(|activity| { ( activity.action_digest.clone(), - format!("member:{}", activity.member_id), + crate::domain::gestalt_member_subject_id(&activity.member_id), ) })) .map( @@ -4724,7 +4725,7 @@ mod tests { let actor = materialize_actor( &value.gestalts[destination], member, - &format!("member:{id}"), + &crate::domain::gestalt_member_subject_id(id), location, ); assert_eq!(actor.name, old.name); diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index d40eda6..bf17186 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -213,7 +213,7 @@ pub fn lower_fission( .get(&member.id) .unwrap_or(&preview.residual_child_id); mutations.push(WorldMutation::ChangePopulationMembership { - actor: actor_subject(&format!("member:{}", member.id)), + actor: actor_subject(&crate::domain::gestalt_member_subject_id(&member.id)), operation: PopulationMembershipOperation::Transfer, source_population: Some(parent.clone()), destination_population: Some(population_subject(destination)), @@ -657,7 +657,7 @@ fn project_accepted_fission( .member_child_assignments .get(&member_id) .unwrap_or(&preview.residual_child_id); - let actor = actor_subject(&format!("member:{member_id}")); + let actor = actor_subject(&crate::domain::gestalt_member_subject_id(&member_id)); let active_destinations = next .memberships .iter() @@ -1043,7 +1043,7 @@ pub fn lower_strategic_wave( .last_location_id .as_deref() .unwrap_or(&source.home_location_id); - let subject = actor_subject(&format!("member:{}", action.member_id)); + let subject = actor_subject(&crate::domain::gestalt_member_subject_id(&action.member_id)); if origin != action.destination_location_id { mutations.push(WorldMutation::Relocate { subject: subject.clone(), @@ -1196,7 +1196,7 @@ fn push_strategic_outcome_mutations( StrategicOutcomeEffect::MemberMemory { member_id, memory } => { ensure_member(campaign, member_id)?; mutations.push(WorldMutation::ChangeMemory { - subject: actor_subject(&format!("member:{member_id}")), + subject: actor_subject(&crate::domain::gestalt_member_subject_id(member_id)), operation: MemoryMutationOperation::Record, memory_id: format!( "memory:strategic:{}:{}", @@ -1213,7 +1213,7 @@ fn push_strategic_outcome_mutations( } => { ensure_member(campaign, member_id)?; mutations.push(WorldMutation::ChangeCommitment { - subject: actor_subject(&format!("member:{member_id}")), + subject: actor_subject(&crate::domain::gestalt_member_subject_id(member_id)), operation: CommitmentMutationOperation::Create, kind: CommitmentKind::Obligation, commitment_id: format!( @@ -1233,7 +1233,7 @@ fn push_strategic_outcome_mutations( let member = ensure_member(campaign, member_id)?; let target = resolve_subject(campaign, other_subject_id) .ok_or_else(|| anyhow!("strategic member relationship target is unknown"))?; - let source = actor_subject(&format!("member:{member_id}")); + let source = actor_subject(&crate::domain::gestalt_member_subject_id(member_id)); mutations.push(WorldMutation::ChangeRelationship { source: source.clone(), target, @@ -1781,7 +1781,7 @@ fn component_snapshot(campaign: &Campaign) -> Result { } } for member in campaign.gestalt_members.values() { - let subject = actor_subject(&format!("member:{}", member.id)); + let subject = actor_subject(&crate::domain::gestalt_member_subject_id(&member.id)); let gestalt = campaign .gestalts .get(&member.gestalt_id) @@ -1811,12 +1811,13 @@ fn component_snapshot(campaign: &Campaign) -> Result { .unwrap_or(&gestalt.home_location_id), ), ); + let identity_id = format!("identity:canonical:{}", subject.id); state .identities - .entry(format!("identity:canonical:member:{}", member.id)) + .entry(identity_id.clone()) .or_insert_with(|| IdentityHandleState { schema: "ghostlight.identity_handle.v1".into(), - id: format!("identity:canonical:member:{}", member.id), + id: identity_id, subject: subject.clone(), value: member.name.clone(), active: true, @@ -2833,7 +2834,7 @@ fn legacy_resources(campaign: &Campaign) -> BTreeSet<(SubjectRef, String)> { } } for member in campaign.gestalt_members.values() { - let owner = actor_subject(&format!("member:{}", member.id)); + let owner = actor_subject(&crate::domain::gestalt_member_subject_id(&member.id)); for resource in &member.equipment { resources.insert((owner.clone(), resource.clone())); } diff --git a/crates/ghostlight-dungeon/src/outcome.rs b/crates/ghostlight-dungeon/src/outcome.rs index a85b5dc..6623ec0 100644 --- a/crates/ghostlight-dungeon/src/outcome.rs +++ b/crates/ghostlight-dungeon/src/outcome.rs @@ -508,7 +508,7 @@ pub fn validate_plan_activity_outcomes( ( activity.action_digest.clone(), CellActionProposal { - subject_id: format!("member:{}", activity.member_id), + subject_id: crate::domain::gestalt_member_subject_id(&activity.member_id), intent: "committed strategic activity".into(), intended_effect: "resolve the committed strategic activity".into(), priority: 0, @@ -820,14 +820,20 @@ fn resolved_outcome_summary( } StrategicOutcomeEffect::MemberMemory { member_id, memory } => format!( "{} retains a new memory: {memory}.", - subject_name(campaign, &format!("member:{member_id}"))? + subject_name( + campaign, + &crate::domain::gestalt_member_subject_id(member_id) + )? ), StrategicOutcomeEffect::MemberObligation { member_id, obligation, } => format!( "{} accepts an obligation: {obligation}.", - subject_name(campaign, &format!("member:{member_id}"))? + subject_name( + campaign, + &crate::domain::gestalt_member_subject_id(member_id) + )? ), StrategicOutcomeEffect::MemberRelationship { member_id, @@ -835,7 +841,10 @@ fn resolved_outcome_summary( description, } => format!( "{}'s relationship with {} becomes: {description}.", - subject_name(campaign, &format!("member:{member_id}"))?, + subject_name( + campaign, + &crate::domain::gestalt_member_subject_id(member_id) + )?, subject_name(campaign, other_subject_id)? ), StrategicOutcomeEffect::KnowledgeLearned { diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index 80f2a96..7ed6bf9 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -61,6 +61,8 @@ impl CampaignRegistry { .load::("campaign.v1", &keys[0])? .ok_or_else(|| anyhow!("campaign row vanished during migration"))?; let before = campaign.clone(); + let normalized_member_identities = + crate::resolution::normalize_legacy_gestalt_member_identities(&mut campaign)?; crate::resolution::ensure_agency_profiles(&mut campaign); if campaign != before { let previous_resolution_epoch = campaign.resolution_policy.resolution_epoch; @@ -79,7 +81,11 @@ impl CampaignRegistry { provider_configuration_epoch: campaign .resolution_policy .provider_configuration_epoch, - operation: "migrate_flat_agency_state".into(), + operation: if normalized_member_identities { + "normalize_gestalt_member_identity".into() + } else { + "migrate_flat_agency_state".into() + }, active_cell_budget: campaign.resolution_policy.active_cell_budget, pin_ids: campaign.resolution_pins.keys().cloned().collect(), committed_at: Utc::now(), diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index f87fb6b..a2560da 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -116,6 +116,248 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) { ensure_structural_relations(campaign); } +/// Normalize the one historical compiler shape that stored the world-subject +/// `member:` namespace inside a Gestalt member's local ID. This migrates only +/// current canonical references. Event history and persisted receipts remain +/// immutable witnesses of the version that produced them. +pub(crate) fn normalize_legacy_gestalt_member_identities(campaign: &mut Campaign) -> Result { + let mut next = campaign.clone(); + let changed = normalize_legacy_gestalt_member_identities_inner(&mut next)?; + if changed { + *campaign = next; + } + Ok(changed) +} + +fn normalize_legacy_gestalt_member_identities_inner(campaign: &mut Campaign) -> Result { + let mut changed = false; + let mut subject_aliases = BTreeMap::new(); + let mut normalized_members = BTreeMap::new(); + + for (stored_key, mut member) in std::mem::take(&mut campaign.gestalt_members) { + if stored_key != member.id { + return Err(anyhow!( + "gestalt member map key {:?} disagrees with record ID {:?}", + stored_key, + member.id + )); + } + let old_local_id = member.id.clone(); + let local_id = crate::domain::canonical_gestalt_member_local_id(&old_local_id); + if local_id.is_empty() { + return Err(anyhow!("gestalt member has an empty canonical local ID")); + } + let canonical_subject_id = crate::domain::gestalt_member_subject_id(&local_id); + let legacy_subject_id = format!("member:{old_local_id}"); + if old_local_id != local_id { + changed = true; + insert_identity_alias( + &mut subject_aliases, + legacy_subject_id.clone(), + canonical_subject_id.clone(), + )?; + } + + match member.materialized_actor_id.clone() { + Some(old_actor_id) => { + let actor = campaign.actors.get(&old_actor_id).ok_or_else(|| { + anyhow!( + "materialized gestalt member {} references missing actor {}", + old_local_id, + old_actor_id + ) + })?; + if actor.id != old_actor_id { + return Err(anyhow!( + "materialized actor map key {:?} disagrees with record ID {:?}", + old_actor_id, + actor.id + )); + } + if old_actor_id != canonical_subject_id { + changed = true; + insert_identity_alias( + &mut subject_aliases, + old_actor_id, + canonical_subject_id.clone(), + )?; + } + member.materialized_actor_id = Some(canonical_subject_id); + } + None => { + if campaign.actors.contains_key(&canonical_subject_id) + || (legacy_subject_id != canonical_subject_id + && campaign.actors.contains_key(&legacy_subject_id)) + { + return Err(anyhow!( + "dormant gestalt member {} collides with a materialized actor", + old_local_id + )); + } + } + } + member.id = local_id.clone(); + if normalized_members + .insert(local_id.clone(), member) + .is_some() + { + return Err(anyhow!( + "gestalt member IDs collide after normalization at {local_id}" + )); + } + } + campaign.gestalt_members = normalized_members; + + if !changed { + return Ok(false); + } + + let mut normalized_actors = BTreeMap::new(); + for (stored_key, mut actor) in std::mem::take(&mut campaign.actors) { + if stored_key != actor.id { + return Err(anyhow!( + "actor map key {:?} disagrees with record ID {:?}", + stored_key, + actor.id + )); + } + let actor_id = rewritten_subject_id(&subject_aliases, &stored_key); + actor.id = actor_id.clone(); + if normalized_actors.insert(actor_id.clone(), actor).is_some() { + return Err(anyhow!( + "actor IDs collide after gestalt member normalization at {actor_id}" + )); + } + } + campaign.actors = normalized_actors; + campaign.player_actor_id = rewritten_subject_id(&subject_aliases, &campaign.player_actor_id); + + for actor in campaign.actors.values_mut() { + rewrite_relationship_targets(&mut actor.relationships, &subject_aliases)?; + } + for member in campaign.gestalt_members.values_mut() { + rewrite_relationship_targets(&mut member.relationships, &subject_aliases)?; + } + for proposal in &mut campaign.pending_world_proposals { + proposal.actor_id = rewritten_subject_id(&subject_aliases, &proposal.actor_id); + for reference in &mut proposal.state_references { + *reference = rewritten_state_reference(&subject_aliases, reference); + } + } + + let mut normalized_profiles = BTreeMap::new(); + for (stored_key, mut profile) in std::mem::take(&mut campaign.agency_profiles) { + let subject_id = rewritten_subject_id(&subject_aliases, &profile.subject_id); + let profile_key = rewritten_subject_id(&subject_aliases, &stored_key); + if profile_key != subject_id { + return Err(anyhow!( + "agency profile key {:?} disagrees with normalized subject {:?}", + profile_key, + subject_id + )); + } + if let Some(old_subject_id) = subject_aliases + .iter() + .find_map(|(old, new)| (new == &subject_id).then_some(old)) + { + let legacy_profile_id = format!("agency-profile:{old_subject_id}"); + if profile.id == legacy_profile_id { + profile.id = format!("agency-profile:{subject_id}"); + } + } + profile.subject_id = subject_id.clone(); + profile.collective_authority_id = profile + .collective_authority_id + .as_deref() + .map(|id| rewritten_subject_id(&subject_aliases, id)); + profile.parent_subject_id = profile + .parent_subject_id + .as_deref() + .map(|id| rewritten_subject_id(&subject_aliases, id)); + if normalized_profiles + .insert(subject_id.clone(), profile) + .is_some() + { + return Err(anyhow!( + "agency profiles collide after gestalt member normalization at {subject_id}" + )); + } + } + campaign.agency_profiles = normalized_profiles; + + for relation in campaign.agency_relations.values_mut() { + relation.from_subject_id = + rewritten_subject_id(&subject_aliases, &relation.from_subject_id); + relation.to_subject_id = rewritten_subject_id(&subject_aliases, &relation.to_subject_id); + } + for pin in campaign.resolution_pins.values_mut() { + pin.subject_ids = pin + .subject_ids + .iter() + .map(|id| rewritten_subject_id(&subject_aliases, id)) + .collect(); + } + campaign.resolution_cover = None; + Ok(true) +} + +fn insert_identity_alias( + aliases: &mut BTreeMap, + old: String, + new: String, +) -> Result<()> { + if old == new { + return Ok(()); + } + if aliases + .insert(old.clone(), new.clone()) + .is_some_and(|prior| prior != new) + { + return Err(anyhow!( + "legacy subject {old} maps to multiple canonical identities" + )); + } + Ok(()) +} + +fn rewritten_subject_id(aliases: &BTreeMap, value: &str) -> String { + aliases + .get(value) + .cloned() + .unwrap_or_else(|| value.to_owned()) +} + +fn rewritten_state_reference(aliases: &BTreeMap, value: &str) -> String { + if let Some(rewritten) = aliases.get(value) { + return rewritten.clone(); + } + for namespace in ["subject:", "actor:"] { + if let Some(subject_id) = value.strip_prefix(namespace) + && let Some(rewritten) = aliases.get(subject_id) + { + return format!("{namespace}{rewritten}"); + } + } + value.to_owned() +} + +fn rewrite_relationship_targets( + relationships: &mut BTreeMap, + aliases: &BTreeMap, +) -> Result<()> { + let mut normalized = BTreeMap::new(); + for (subject_id, description) in std::mem::take(relationships) { + let subject_id = rewritten_subject_id(aliases, &subject_id); + if normalized.insert(subject_id.clone(), description).is_some() { + return Err(anyhow!( + "relationship targets collide after gestalt member normalization at {subject_id}" + )); + } + } + *relationships = normalized; + Ok(()) +} + fn base_profile( subject_id: &str, kind: AgencySubjectKind, @@ -2510,7 +2752,7 @@ pub fn member_state_references(campaign: &Campaign, member_id: &str) -> Result BTre source .location_ids .contains(&location) - .then(|| format!("member:{}", member.id)) + .then(|| crate::domain::gestalt_member_subject_id(&member.id)) })); } targets @@ -2741,7 +2983,7 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result Result( campaign: &'a Campaign, - subject_id: &'a str, + subject_id: &str, ) -> Option<&'a str> { - let member_id = subject_id.strip_prefix("member:")?; campaign .gestalt_members - .get(member_id) + .values() .filter(|member| { - member.materialized_actor_id.is_none() && !campaign.actors.contains_key(subject_id) + member.materialized_actor_id.is_none() + && crate::domain::gestalt_member_subject_id(&member.id) == subject_id + && !campaign.actors.contains_key(subject_id) }) - .map(|_| member_id) + .map(|member| member.id.as_str()) + .next() } pub fn validate_active_gestalt_presence_location( @@ -3254,6 +3498,151 @@ pub(crate) mod tests { assert_eq!(dormant_member_id_for_subject(&value, "member:sable"), None); } + #[test] + fn legacy_prefixed_member_identity_migrates_once_without_losing_person_state() { + let mut value = campaign(1, 1); + value.gestalts.insert( + "raincross-households".into(), + GestaltPersonaState { + schema: "ghostlight.gestalt_persona_state.v1".into(), + id: "raincross-households".into(), + name: "Raincross households".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( + "member:cal_rusk".into(), + GestaltMemberDelta { + schema: "ghostlight.gestalt_member_delta.v1".into(), + id: "member:cal_rusk".into(), + gestalt_id: "raincross-households".into(), + version: 3, + name: "Cal Rusk".into(), + capability_additions: BTreeSet::from(["read flood trails".into()]), + capability_removals: BTreeSet::new(), + knowledge_additions: BTreeSet::from(["the bypass is passable".into()]), + knowledge_removals: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::from([( + "member:member:cal_rusk".into(), + "trusts his own survey".into(), + )]), + goals: vec!["keep the households alive".into()], + memories: vec!["surveyed the bypass".into()], + last_location_id: Some("center".into()), + materialized_actor_id: Some("member:member:cal_rusk".into()), + last_relevant_revision: 5, + relevance_lease_until_revision: 9, + }, + ); + value.actors.insert( + "member:member:cal_rusk".into(), + ActorState { + id: "member:member:cal_rusk".into(), + name: "Cal Rusk".into(), + location_id: "center".into(), + capabilities: BTreeSet::from(["read flood trails".into()]), + knowledge: BTreeSet::from(["the bypass is passable".into()]), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec!["keep the households alive".into()], + memories: vec!["surveyed the bypass".into()], + }, + ); + value.actors["player"].relationships.insert( + "member:member:cal_rusk".into(), + "heard his careful report".into(), + ); + value.pending_world_proposals.push(WorldActionProposal { + actor_id: "member:member:cal_rusk".into(), + intent: "check the route".into(), + intended_effect: "keep the route legible".into(), + priority: 1, + state_references: vec!["subject:member:member:cal_rusk".into()], + }); + ensure_agency_profiles(&mut value); + value + .agency_profiles + .get_mut("member:member:cal_rusk") + .unwrap() + .detail_debt = 17; + value.agency_relations.insert( + "cal-households".into(), + AgencyRelation { + schema: "ghostlight.agency_relation.v1".into(), + id: "cal-households".into(), + from_subject_id: "member:member:cal_rusk".into(), + to_subject_id: "raincross-households".into(), + kind: AgencyRelationKind::Membership, + strength: 100, + active: true, + evidence_receipt_ids: vec![], + }, + ); + value.resolution_pins.insert( + "pin-cal".into(), + ResolutionPin { + schema: "ghostlight.resolution_pin.v1".into(), + id: "pin-cal".into(), + kind: ResolutionPinKind::MinimumIndividualDetail, + subject_ids: BTreeSet::from(["member:member:cal_rusk".into()]), + reason: "foreground".into(), + created_world_revision: 5, + }, + ); + value.resolution_cover = + Some(plan_cover(&value, default_demand(&value, "legacy cover")).unwrap()); + + assert!(normalize_legacy_gestalt_member_identities(&mut value).unwrap()); + assert!(!normalize_legacy_gestalt_member_identities(&mut value).unwrap()); + assert!(!value.gestalt_members.contains_key("member:cal_rusk")); + let member = &value.gestalt_members["cal_rusk"]; + assert_eq!(member.id, "cal_rusk"); + assert_eq!( + member.materialized_actor_id.as_deref(), + Some("member:cal_rusk") + ); + assert!( + member + .knowledge_additions + .contains("the bypass is passable") + ); + assert!(member.memories.contains(&"surveyed the bypass".into())); + assert!(member.relationships.contains_key("member:cal_rusk")); + assert!(!value.actors.contains_key("member:member:cal_rusk")); + assert_eq!(value.actors["member:cal_rusk"].name, "Cal Rusk"); + assert!( + value.actors["player"] + .relationships + .contains_key("member:cal_rusk") + ); + assert_eq!(value.pending_world_proposals[0].actor_id, "member:cal_rusk"); + assert_eq!( + value.pending_world_proposals[0].state_references, + ["subject:member:cal_rusk"] + ); + assert_eq!(value.agency_profiles["member:cal_rusk"].detail_debt, 17); + assert_eq!( + value.agency_relations["cal-households"].from_subject_id, + "member:cal_rusk" + ); + assert_eq!( + value.resolution_pins["pin-cal"].subject_ids, + BTreeSet::from(["member:cal_rusk".into()]) + ); + assert!(value.resolution_cover.is_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 b9389ea..1139871 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -699,7 +699,7 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result Result