Seal mutation ownership and atomic versioning
This commit is contained in:
parent
d730d1fd70
commit
6fbe47ad88
3 changed files with 165 additions and 49 deletions
|
|
@ -155,6 +155,15 @@ fn execute_mutation_batch(
|
|||
batch: crate::transition::WorldMutationBatch,
|
||||
) -> Result<CommandResult, KernelError> {
|
||||
let key = batch.campaign_id.to_string();
|
||||
if store
|
||||
.load::<Campaign>("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::<crate::transition::ComponentWorldState>("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::<ComponentWorldState>("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::<crate::domain::StrategicTickReceipt>("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);
|
||||
|
|
|
|||
|
|
@ -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<SubjectRef>,
|
||||
) -> 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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue