From 6fbe47ad884b01dd6ce7c2e4e3fd65173f29c46b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 14:42:27 +0200 Subject: [PATCH] Seal mutation ownership and atomic versioning --- crates/ghostlight-dungeon/src/kernel.rs | 90 +++++++++++++++---- .../src/legacy_transition.rs | 66 +++++++------- crates/ghostlight-dungeon/src/transition.rs | 58 ++++++++++++ 3 files changed, 165 insertions(+), 49 deletions(-) diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index b750fce..585efaa 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -155,6 +155,15 @@ fn execute_mutation_batch( batch: crate::transition::WorldMutationBatch, ) -> Result { let key = batch.campaign_id.to_string(); + if store + .load::("campaign.v1", &key) + .map_err(persist)? + .is_some() + { + return Err(KernelError::Invalid( + "aggregate campaigns accept mutations only through their WorldCommand mailbox".into(), + )); + } let (row, state) = store .load::("component_world_state.v1", &key) .map_err(persist)? @@ -2977,10 +2986,11 @@ fn commit_governed_group_travel( .locations .get(&proposal.origin_location_id) .and_then(|location| { - location.routes.iter().find_map(|(route_id, route)| { - (route.destination_id == proposal.destination_location_id) - .then_some(route.travel_minutes) - }) + location + .routes + .values() + .find(|route| route.destination_id == proposal.destination_location_id) + .map(|route| route.travel_minutes) }) .ok_or_else(|| KernelError::Invalid("group-travel route no longer exists".into()))?; if route_minutes != proposal.travel_minutes { @@ -3162,6 +3172,15 @@ mod tests { plan } + fn assert_only_strategic_obligations_advanced(before: &Campaign, after: &Campaign) { + let mut expected = before.clone(); + expected.world_time += Duration::hours(i64::from(expected.tick_hours)); + for clock in expected.clocks.values_mut() { + clock.progress = clock.progress.saturating_add(1).min(clock.threshold); + } + assert_eq!(after, &expected); + } + fn campaign() -> Campaign { let id = uuid::Uuid::new_v4(); let actor = ActorState { @@ -3339,7 +3358,7 @@ mod tests { assert!( kernel - .commit_mutation_batch(authority, batch) + .commit_mutation_batch(authority.clone(), batch.clone()) .await .is_err() ); @@ -3350,6 +3369,26 @@ mod tests { .1; assert_eq!(stored.revision, 8); assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); + + let mut aggregate = campaign(); + aggregate.id = campaign_id; + store.create_campaign(&aggregate, &[], &[]).unwrap(); + let error = kernel + .commit_mutation_batch(authority, batch) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("only through their WorldCommand mailbox") + ); + let stored = store + .load::("component_world_state.v1", &campaign_id.to_string()) + .unwrap() + .unwrap() + .1; + assert_eq!(stored.revision, 8); + assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); } fn hierarchical_refugee_campaign() -> Campaign { @@ -3795,7 +3834,7 @@ mod tests { assert_eq!(actor.memories, old.memories); } for (id, before, version_increment) in [ - ("refugees-east", source_before, 2), + ("refugees-east", source_before, 1), ("dock-neighbors", docks_before, 1), ("hill-neighbors", hills_before, 1), ] { @@ -4073,7 +4112,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!(events.len(), 2); assert_eq!(events[0].kind, "gestalt_activity"); assert_eq!( @@ -4274,7 +4313,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!( events[0].summary, "Eastern transit refugees begins a local investigation." @@ -4302,7 +4341,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!( events[0].summary, "Eastern transit refugees attempts a local communication." @@ -4331,7 +4370,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!( events[0].summary, "Eastern transit refugees attempts local interference." @@ -4361,7 +4400,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!( events[0].summary, "Mira Venn attempts a local communication." @@ -4396,7 +4435,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!(events.len(), 2); assert_eq!( events[0].summary, @@ -4488,7 +4527,7 @@ mod tests { }), ) .unwrap(); - assert_eq!(value, before); + assert_only_strategic_obligations_advanced(&before, &value); assert_eq!(events.len(), 2); assert_eq!(events[0].kind, "gestalt_member_activity"); assert_eq!( @@ -4533,6 +4572,12 @@ mod tests { .unwrap(); assert_eq!(stored.revision, 1); assert_eq!(stored.world_time, seed.world_time + Duration::minutes(30)); + assert_eq!( + store.keys("mutation_authority_envelope.v1").unwrap().len(), + 1 + ); + assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1); + assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); } #[tokio::test] @@ -4799,7 +4844,7 @@ mod tests { } #[tokio::test] - async fn strategic_tick_persists_channel_news_without_using_legacy_host_as_truth() { + async fn strategic_tick_without_an_admitted_wave_advances_only_deterministic_obligations() { let dir = tempfile::tempdir().unwrap(); let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); let kernel = WorldKernel::start(store.clone()); @@ -4835,18 +4880,19 @@ mod tests { let CommandResult::Committed { campaign, .. } = result else { panic!("expected commit") }; - assert_eq!(campaign.events.len(), 1); - assert_eq!(campaign.news.len(), 1); - assert_eq!(campaign.news[0].channel, "institution:board"); + assert!(campaign.events.is_empty()); + assert!(campaign.news.is_empty()); assert_eq!(campaign.away_ticks_processed, 1); - assert!(campaign.institutions["board"].posture.contains("acting")); + assert_eq!(campaign.institutions["board"].posture, "watching"); + assert_eq!(campaign.world_time, seed.world_time + Duration::hours(6)); let ticks = store .load_all::("strategic_tick.v1") .unwrap(); assert_eq!(ticks.len(), 1); assert_eq!(ticks[0].source, TickSource::Scheduler); - assert_eq!(ticks[0].event_ids, vec![campaign.events[0].id.clone()]); + assert!(ticks[0].event_ids.is_empty()); assert!(ticks[0].model_receipt_hash.is_none()); + assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); } #[tokio::test] @@ -6526,6 +6572,12 @@ mod tests { campaign.agency_profiles["guest"].location_ids, BTreeSet::from(["harbor".into()]) ); + assert_eq!( + store.keys("mutation_authority_envelope.v1").unwrap().len(), + 1 + ); + assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1); + assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); let mut reload_projection = campaign.clone(); crate::resolution::ensure_agency_profiles(&mut reload_projection); assert_eq!(reload_projection, campaign); diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index f708998..30633d8 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -1328,6 +1328,8 @@ fn project_mutated_components( ) -> Result<()> { let mut resources_changed = false; let mut resource_owners_changed = BTreeSet::new(); + let mut touched_members = BTreeSet::new(); + let mut touched_gestalts = BTreeSet::new(); for permitted in &batch.mutations { match &permitted.mutation { WorldMutation::TransferCustody { @@ -1354,6 +1356,7 @@ fn project_mutated_components( .get_mut(member_id) .ok_or_else(|| anyhow!("accepted relocation member vanished"))?; member.last_location_id = Some(destination.id.clone()); + touched_members.insert(member_id.to_string()); if let Some(actor) = campaign.actors.get_mut(&subject.id) { actor.location_id = destination.id.clone(); } @@ -1375,7 +1378,7 @@ fn project_mutated_components( .get_mut(&subject.id) .ok_or_else(|| anyhow!("accepted relocation population vanished"))?; gestalt.home_location_id = destination.id.clone(); - gestalt.version = gestalt.version.saturating_add(1); + touched_gestalts.insert(subject.id.clone()); let profile = campaign .agency_profiles .get_mut(&subject.id) @@ -1426,7 +1429,7 @@ fn project_mutated_components( .ok_or_else(|| anyhow!("accepted knowledge member vanished"))?; member.knowledge_removals.remove(&statement); member.knowledge_additions.insert(statement.clone()); - member.version = member.version.saturating_add(1); + touched_members.insert(member_id.to_string()); if let Some(actor) = campaign.actors.get_mut(&subject.id) { actor.knowledge.insert(statement.clone()); } @@ -1445,7 +1448,7 @@ fn project_mutated_components( .get_mut(&subject.id) .ok_or_else(|| anyhow!("accepted knowledge gestalt vanished"))?; gestalt.shared_knowledge.insert(statement.clone()); - gestalt.version = gestalt.version.saturating_add(1); + touched_gestalts.insert(subject.id.clone()); } _ => { return Err(anyhow!( @@ -1499,7 +1502,7 @@ fn project_mutated_components( actor.relationships.remove(&target.id); } } - member.version = member.version.saturating_add(1); + touched_members.insert(member_id.to_string()); } else { let actor = campaign .actors @@ -1529,7 +1532,7 @@ fn project_mutated_components( .ok_or_else(|| anyhow!("accepted memory member vanished"))?; if !member.memories.contains(&value.summary) { member.memories.push(value.summary.clone()); - member.version = member.version.saturating_add(1); + touched_members.insert(member_id.to_string()); } if let Some(actor) = campaign.actors.get_mut(&subject.id) { if actor.memories.len() < 64 && !actor.memories.contains(&value.summary) { @@ -1577,7 +1580,7 @@ fn project_mutated_components( }); } } - member.version = member.version.saturating_add(1); + touched_members.insert(member_id.to_string()); if let Some(actor) = campaign.actors.get_mut(&subject.id) { match (kind, value) { (CommitmentKind::Goal, Some(value)) => { @@ -1642,7 +1645,7 @@ fn project_mutated_components( if !value.resolved { gestalt.pressures.push(value.label.clone()); } - gestalt.version = gestalt.version.saturating_add(1); + touched_gestalts.insert(value.owner.id.clone()); } else { return Err(anyhow!("aggregate pressure owner is unsupported")); } @@ -1699,6 +1702,9 @@ fn project_mutated_components( &source.id, &destination.id, )?; + touched_members.insert(member_id.to_string()); + touched_gestalts.insert(source.id.clone()); + touched_gestalts.insert(destination.id.clone()); } WorldMutation::AdvanceWorldTime { .. } => { campaign.world_time = next.world_time; @@ -1712,7 +1718,28 @@ fn project_mutated_components( } } if resources_changed { - project_all_resources(campaign, next, &resource_owners_changed)?; + project_all_resources(campaign, next)?; + for owner in resource_owners_changed { + match owner.kind { + SubjectKind::Actor if owner.id.starts_with("member:") => { + touched_members.insert(owner.id.trim_start_matches("member:").to_string()); + } + SubjectKind::Population => { + touched_gestalts.insert(owner.id); + } + _ => {} + } + } + } + for member_id in touched_members { + if let Some(member) = campaign.gestalt_members.get_mut(&member_id) { + member.version = member.version.saturating_add(1); + } + } + for gestalt_id in touched_gestalts { + if let Some(gestalt) = campaign.gestalts.get_mut(&gestalt_id) { + gestalt.version = gestalt.version.saturating_add(1); + } } Ok(()) } @@ -1792,15 +1819,10 @@ fn project_member_population_transfer( effective_goals }; member.gestalt_id = destination_population_id.into(); - member.version = member.version.saturating_add(1); Ok(()) } -fn project_all_resources( - campaign: &mut Campaign, - next: &ComponentWorldState, - changed_owners: &BTreeSet, -) -> Result<()> { +fn project_all_resources(campaign: &mut Campaign, next: &ComponentWorldState) -> Result<()> { for actor in campaign.actors.values_mut() { actor.equipment.clear(); } @@ -1856,22 +1878,6 @@ fn project_all_resources( _ => return Err(anyhow!("aggregate resource custodian is unsupported")), } } - for owner in changed_owners { - match owner.kind { - SubjectKind::Actor if owner.id.starts_with("member:") => { - let member_id = owner.id.trim_start_matches("member:"); - if let Some(member) = campaign.gestalt_members.get_mut(member_id) { - member.version = member.version.saturating_add(1); - } - } - SubjectKind::Population => { - if let Some(gestalt) = campaign.gestalts.get_mut(&owner.id) { - gestalt.version = gestalt.version.saturating_add(1); - } - } - _ => {} - } - } for institution in campaign.institutions.values_mut() { institution.resources.sort(); institution.resources.dedup(); diff --git a/crates/ghostlight-dungeon/src/transition.rs b/crates/ghostlight-dungeon/src/transition.rs index e67c0a2..5097c4b 100644 --- a/crates/ghostlight-dungeon/src/transition.rs +++ b/crates/ghostlight-dungeon/src/transition.rs @@ -4266,6 +4266,64 @@ mod tests { assert_eq!(invalid_state.revision, state.revision); } + #[test] + fn exact_relocation_permit_cannot_cross_a_closed_or_different_route() { + let state = component_world(); + let sable = subject(SubjectKind::Actor, "actor:sable"); + let camp = subject(SubjectKind::Place, "place:camp"); + let trail = subject(SubjectKind::Place, "place:trail"); + let permit = permit( + "permit:move", + WorldMutationOperation::Relocate, + vec![ + ( + MutationSubjectRole::Subject, + BTreeSet::from([sable.clone()]), + ), + ( + MutationSubjectRole::OriginPlace, + BTreeSet::from([camp.clone()]), + ), + ( + MutationSubjectRole::DestinationPlace, + BTreeSet::from([trail.clone()]), + ), + ], + vec![(MutationStringRole::RouteId, exact(&["route:camp-trail"]))], + vec![], + ); + let envelope = envelope_for(&state, vec![permit]); + let batch = batch_for( + &state, + &envelope, + vec![( + "permit:move", + WorldMutation::Relocate { + subject: sable.clone(), + from_place: camp.clone(), + to_place: trail.clone(), + route_id: "route:camp-trail".into(), + }, + )], + ); + + let mut closed = state.clone(); + closed.topology.get_mut("route:camp-trail").unwrap().open = false; + assert!(apply_component_world_batch(&closed, &envelope, &batch, Utc::now()).is_err()); + assert_eq!(closed.occupancy[&sable], camp); + assert_eq!(closed.revision, state.revision); + + let mut redirected = state.clone(); + redirected + .topology + .get_mut("route:camp-trail") + .unwrap() + .to_place = subject(SubjectKind::Place, "place:east-trail"); + assert!(apply_component_world_batch(&redirected, &envelope, &batch, Utc::now()).is_err()); + assert_eq!(redirected.occupancy[&sable], camp); + assert_eq!(redirected.revision, state.revision); + } + #[test] fn resource_split_and_transfer_conserve_quantity_and_exact_custody() { let state = component_world();