Preserve canonical Gestalt member identity
This commit is contained in:
parent
dab3612a4c
commit
f1fee54bed
11 changed files with 482 additions and 42 deletions
|
|
@ -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, ..
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
if collisions.is_empty() {
|
||||
|
|
@ -2723,7 +2726,7 @@ fn canonical_relationship_subject_ids(campaign: &Campaign) -> BTreeSet<String> {
|
|||
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<const N: usize>(
|
|||
}
|
||||
}
|
||||
|
||||
fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result<Campaign> {
|
||||
fn seed_to_campaign(mut seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result<Campaign> {
|
||||
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"]},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ComponentWorldState> {
|
|||
}
|
||||
}
|
||||
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<ComponentWorldState> {
|
|||
.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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ impl CampaignRegistry {
|
|||
.load::<Campaign>("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(),
|
||||
|
|
|
|||
|
|
@ -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<bool> {
|
||||
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<bool> {
|
||||
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<String, String>,
|
||||
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<String, String>, value: &str) -> String {
|
||||
aliases
|
||||
.get(value)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn rewritten_state_reference(aliases: &BTreeMap<String, String>, 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<String, String>,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
) -> 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<B
|
|||
.get(&member.gestalt_id)
|
||||
.ok_or_else(|| anyhow!("gestalt member baseline is unknown"))?;
|
||||
let mut references = BTreeSet::from([
|
||||
format!("member:{member_id}"),
|
||||
crate::domain::gestalt_member_subject_id(member_id),
|
||||
format!("gestalt:{}", member.gestalt_id),
|
||||
format!(
|
||||
"location:{}",
|
||||
|
|
@ -2726,7 +2968,7 @@ pub fn strategic_activity_targets(campaign: &Campaign, subject_id: &str) -> 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<B
|
|||
dormant_member_location(campaign, member_id)?;
|
||||
let mut targets = strategic_activity_targets(campaign, &member.gestalt_id);
|
||||
targets.insert(member.gestalt_id.clone());
|
||||
targets.remove(&format!("member:{member_id}"));
|
||||
targets.remove(&crate::domain::gestalt_member_subject_id(member_id));
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
|
|
@ -2750,16 +2992,18 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<B
|
|||
/// 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,
|
||||
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] {
|
||||
|
|
|
|||
|
|
@ -699,7 +699,7 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
|
|||
member.goals.clone()
|
||||
};
|
||||
let mut permitted_state_references = BTreeSet::from([
|
||||
format!("member:{}", member.id),
|
||||
crate::domain::gestalt_member_subject_id(&member.id),
|
||||
format!("gestalt:{}", member.gestalt_id),
|
||||
format!("location:{origin}"),
|
||||
]);
|
||||
|
|
@ -723,7 +723,7 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
|
|||
let migration_destinations = migration_destination_slices(campaign, &destinations)?;
|
||||
let activity_targets = activity_target_slices(campaign, &activity_target_ids)?;
|
||||
Ok(CellMemberSlice {
|
||||
subject_id: format!("member:{}", member.id),
|
||||
subject_id: crate::domain::gestalt_member_subject_id(&member.id),
|
||||
member_id: member.id.clone(),
|
||||
name: member.name.clone(),
|
||||
source_gestalt_id: member.gestalt_id.clone(),
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ pub async fn resolve_speech_addresses(
|
|||
return None;
|
||||
}
|
||||
Some((
|
||||
format!("member:{}", member.id),
|
||||
crate::domain::gestalt_member_subject_id(&member.id),
|
||||
serde_json::json!({
|
||||
"public_name": member.name,
|
||||
"presence": "nearby_folded_person",
|
||||
|
|
|
|||
Loading…
Reference in a new issue