diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 5395e10..b750fce 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -298,7 +298,33 @@ fn execute( OutcomeBand::Mixed => assessment.mixed_effect.clone(), OutcomeBand::Failure => assessment.failure_effect.clone(), }; - apply_world_effect(&mut campaign, &effect)?; + let transition = crate::legacy_transition::lower_foreground_effect( + &campaign, + &assessment.intent.actor_id, + &effect, + roll.outcome.clone(), + crate::transition::MutationProcedure::ForegroundAttempt, + &assessment.effect_ceiling, + &assessment.digest, + Some( + crate::legacy_transition::digest_serializable(&assessment.intent) + .map_err(|error| KernelError::Invalid(error.to_string()))?, + ), + Some( + crate::legacy_transition::digest_serializable( + &assessment.intent.intended_effect, + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?, + ), + assessment.expires_at, + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = crate::legacy_transition::apply_lowered_transition( + &mut campaign, + &transition, + Utc::now(), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; refresh_materialized_member_relevance( &mut campaign, std::iter::once(assessment.intent.actor_id.as_str()), @@ -314,7 +340,15 @@ fn execute( speaker: "world".into(), text: text.clone(), }); - commit(store, row, campaign, "attempt", Some(roll)) + commit_mutation_transition( + store, + row, + campaign, + "attempt", + Some(roll), + transition, + mutation_receipt, + ) } WorldCommand::Speak { expected_revision, @@ -365,11 +399,36 @@ fn execute( "wait duration must be between 1 and 1440 minutes".into(), )); } - campaign.world_time += Duration::minutes(i64::from(minutes)); + let source_receipt_id = format!( + "direct-wait:{}:{}:{}", + campaign.id, campaign.revision, minutes + ); + let transition = crate::legacy_transition::lower_time_advance( + &campaign, + minutes, + crate::transition::MutationProcedure::DirectCommand, + &source_receipt_id, + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = crate::legacy_transition::apply_lowered_transition( + &mut campaign, + &transition, + Utc::now(), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; campaign.last_player_activity = Utc::now(); campaign.away_ticks_processed = 0; campaign.pending_ticks = 0; - commit(store, row, campaign, "wait", None) + commit_mutation_transition( + store, + row, + campaign, + "wait", + None, + transition, + mutation_receipt, + ) } WorldCommand::ProposeTimeAdvance { expected_revision, @@ -491,7 +550,12 @@ fn execute( let route = campaign .locations .get(&origin_location_id) - .and_then(|location| location.routes.get(&destination_location_id)) + .and_then(|location| { + location + .routes + .values() + .find(|route| route.destination_id == destination_location_id) + }) .ok_or_else(|| { KernelError::Invalid("destination is not directly reachable".into()) })?; @@ -945,15 +1009,17 @@ fn execute( } } } - campaign.world_time += Duration::hours(i64::from(campaign.tick_hours)); - for clock in campaign.clocks.values_mut() { - clock.progress = clock.progress.saturating_add(1).min(clock.threshold); - } - let tick_number = campaign.strategic_tick_count.saturating_add(1); - let tick_events = match resolved_plan.or(plan) { + let applied_tick = match resolved_plan.or(plan) { Some(plan) => apply_strategic_tick_plan(&mut campaign, plan)?, - None => deterministic_strategic_tick(&mut campaign, tick_number), + None => { + let plan = deterministic_strategic_tick_plan(); + apply_strategic_tick_plan(&mut campaign, plan)? + } }; + let AppliedStrategicTickPlan { + events: tick_events, + mutation, + } = applied_tick; if let Some(wave) = &resolution_wave { crate::resolution::advance_detail_debt(&mut campaign, &wave.cover); campaign.resolution_cover = Some(wave.cover.clone()); @@ -990,6 +1056,7 @@ fn execute( model_receipt_hash, event_ids, resolution_wave, + mutation, ) } WorldCommand::ExpandRegion { @@ -1168,24 +1235,26 @@ fn execute( validate_world_proposal(actor, proposal)?; } } + let source_receipt_id = format!( + "reaction-input:{}", + crate::legacy_transition::digest_serializable(&reactions) + .map_err(|error| KernelError::Invalid(error.to_string()))? + ); + let transition = crate::legacy_transition::lower_reaction_wave( + &campaign, + &witnessed_memory, + &reactions, + &source_receipt_id, + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = crate::legacy_transition::apply_lowered_transition( + &mut campaign, + &transition, + Utc::now(), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; for reaction in reactions { - let actor = campaign - .actors - .get_mut(&reaction.actor_id) - .expect("validated actor"); - if actor.memories.len() < 64 && !actor.memories.contains(&witnessed_memory) { - actor.memories.push(witnessed_memory.clone()); - } - actor - .conditions - .extend(reaction.private_delta.conditions_add); - for value in reaction.private_delta.conditions_remove { - actor.conditions.remove(&value); - } - actor.goals.extend(reaction.private_delta.goals_add); - actor - .relationships - .extend(reaction.private_delta.relationship_updates); if let Some(speech) = reaction.speech { campaign.transcript.push(NarrativeTurn { revision: campaign.revision + 1, @@ -1210,7 +1279,15 @@ fn execute( location_ids: vec![player_location], public_channels: vec![], }); - commit(store, row, campaign, "reaction_wave", None) + commit_mutation_transition( + store, + row, + campaign, + "reaction_wave", + None, + transition, + mutation_receipt, + ) } WorldCommand::ResolveNpcAction { expected_revision, @@ -1275,14 +1352,38 @@ fn execute( OutcomeBand::Mixed => (&assessment.mixed_stake, &assessment.mixed_effect), OutcomeBand::Failure => (&assessment.failure_stake, &assessment.failure_effect), }; - apply_world_effect(&mut campaign, effect)?; + let transition = crate::legacy_transition::lower_foreground_effect( + &campaign, + &proposal.actor_id, + effect, + roll.outcome.clone(), + crate::transition::MutationProcedure::NpcAttempt, + &assessment.effect_ceiling, + &assessment.digest, + Some( + crate::legacy_transition::digest_serializable(&intent) + .map_err(|error| KernelError::Invalid(error.to_string()))?, + ), + Some( + crate::legacy_transition::digest_serializable(&intent.intended_effect) + .map_err(|error| KernelError::Invalid(error.to_string()))?, + ), + assessment.expires_at, + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = crate::legacy_transition::apply_lowered_transition( + &mut campaign, + &transition, + Utc::now(), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; campaign.transcript.push(NarrativeTurn { revision: campaign.revision + 1, at: Utc::now(), speaker: "world".into(), text: text.clone(), }); - Some(roll) + Some((roll, transition, mutation_receipt)) } else { None }; @@ -1305,7 +1406,19 @@ fn execute( &mut campaign, std::iter::once(proposal.actor_id.as_str()), ); - commit(store, row, campaign, "resolve_npc_action", roll) + if let Some((roll, transition, mutation_receipt)) = roll { + commit_mutation_transition( + store, + row, + campaign, + "resolve_npc_action", + Some(roll), + transition, + mutation_receipt, + ) + } else { + commit(store, row, campaign, "resolve_npc_action", None) + } } WorldCommand::CreateCampaign { .. } => unreachable!(), } @@ -1437,66 +1550,6 @@ fn apply_promotion( Ok(()) } -fn apply_world_effect( - campaign: &mut Campaign, - effect: &WorldEffectDelta, -) -> Result<(), KernelError> { - for (actor_id, delta) in &effect.actor_conditions { - let actor = campaign - .actors - .get_mut(actor_id) - .ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?; - actor.conditions.extend(delta.add.clone()); - for value in &delta.remove { - actor.conditions.remove(value); - } - } - for (actor_id, additions) in &effect.actor_knowledge_additions { - let actor = campaign - .actors - .get_mut(actor_id) - .ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?; - actor.knowledge.extend(additions.clone()); - } - for (actor_id, relationships) in &effect.actor_relationship_updates { - campaign - .actors - .get_mut(actor_id) - .ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))? - .relationships - .extend(relationships.clone()); - } - for (actor_id, destination) in &effect.actor_moves { - campaign - .actors - .get_mut(actor_id) - .ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))? - .location_id = destination.clone(); - } - for (clock_id, amount) in &effect.clock_advances { - let clock = campaign - .clocks - .get_mut(clock_id) - .ok_or_else(|| KernelError::Invalid("outcome clock vanished".into()))?; - clock.progress = clock.progress.saturating_add(*amount).min(clock.threshold); - } - for (clock_id, amount) in &effect.clock_reductions { - let clock = campaign - .clocks - .get_mut(clock_id) - .ok_or_else(|| KernelError::Invalid("outcome clock vanished".into()))?; - clock.progress = clock.progress.saturating_sub(*amount); - } - for (institution_id, posture) in &effect.institution_postures { - campaign - .institutions - .get_mut(institution_id) - .ok_or_else(|| KernelError::Invalid("outcome institution vanished".into()))? - .posture = posture.clone(); - } - Ok(()) -} - fn apply_demotion(campaign: &mut Campaign, demotion: &GestaltDemotion) -> Result<(), KernelError> { let actor = campaign .actors @@ -1787,12 +1840,30 @@ fn assess(c: &Campaign, intent: ActionIntent) -> ActionAssessment { a } +#[derive(Debug)] +struct AppliedStrategicTickPlan { + events: Vec, + mutation: Option<( + crate::legacy_transition::LoweredLegacyTransition, + crate::transition::WorldMutationReceipt, + )>, +} + +impl std::ops::Deref for AppliedStrategicTickPlan { + type Target = [Event]; + + fn deref(&self) -> &Self::Target { + &self.events + } +} + fn apply_strategic_tick_plan( campaign: &mut Campaign, plan: crate::domain::StrategicTickPlan, -) -> Result, KernelError> { +) -> Result { crate::outcome::validate_plan_activity_outcomes(campaign, &plan) .map_err(|error| KernelError::Invalid(error.to_string()))?; + let lowering_plan = plan.clone(); let activity_outcomes = plan.activity_outcomes.clone(); let mut outcome_event_context = BTreeMap::new(); for activity in &plan.gestalt_activities { @@ -1831,7 +1902,7 @@ fn apply_strategic_tick_plan( // invalid late action cannot leave this primitive partially mutated. let mut next = campaign.clone(); let revision = campaign.revision + 1; - let at = campaign.world_time; + let at = campaign.world_time + Duration::hours(i64::from(campaign.tick_hours)); let mut events = Vec::new(); let mut seen_institutions = BTreeSet::new(); for action in plan.institution_actions { @@ -1855,20 +1926,16 @@ fn apply_strategic_tick_plan( )); } validate_public_channels(&action.public_channels)?; - let institution = next + let institution = campaign .institutions - .get_mut(&action.institution_id) + .get(&action.institution_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented an institution".into()))?; if !crate::resolution::substantive_text_change(&institution.posture, &action.posture) { return Err(KernelError::Invalid( "strategic institution action would not change its posture".into(), )); } - institution.posture = action.posture; - let summary = format!( - "{} adopts posture: {}", - institution.name, institution.posture - ); + let summary = format!("{} adopts posture: {}", institution.name, action.posture); events.push(crate::domain::Event { id: format!("strategic:{revision}:institution:{}", institution.id), at, @@ -1890,9 +1957,9 @@ fn apply_strategic_tick_plan( )); } validate_public_channels(&action.public_channels)?; - let gestalt = next + let gestalt = campaign .gestalts - .get_mut(&action.gestalt_id) + .get(&action.gestalt_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented a gestalt".into()))?; crate::resolution::validate_gestalt_pressure_transition( &gestalt.pressures, @@ -1913,14 +1980,6 @@ fn apply_strategic_tick_plan( action.pressure_additions.join("; ") )); } - let resolved = action.pressure_resolutions.iter().collect::>(); - gestalt - .pressures - .retain(|pressure| !resolved.contains(pressure)); - for pressure in action.pressure_additions { - gestalt.pressures.push(pressure); - } - gestalt.version += 1; events.push(crate::domain::Event { id: format!("strategic:{revision}:gestalt:{}", gestalt.id), at, @@ -1957,18 +2016,6 @@ fn apply_strategic_tick_plan( let destination_name = campaign.gestalts[&action.destination_gestalt_id] .name .clone(); - let gestalt = next - .gestalts - .get_mut(&action.gestalt_id) - .expect("gestalt migration source was validated"); - gestalt.home_location_id = action.destination_location_id.clone(); - gestalt.version = gestalt.version.saturating_add(1); - let profile = next - .agency_profiles - .get_mut(&action.gestalt_id) - .expect("gestalt migration profile was validated"); - profile.location_ids = BTreeSet::from([action.destination_location_id.clone()]); - profile.profile_version = profile.profile_version.saturating_add(1); events.push(crate::domain::Event { id: format!( "strategic:{revision}:gestalt-migration:{}", @@ -2113,10 +2160,6 @@ fn apply_strategic_tick_plan( } let origin = actor.location_id.clone(); let actor_name = actor.name.clone(); - next.actors - .get_mut(&action.actor_id) - .expect("actor was validated") - .location_id = action.destination_id.clone(); events.push(crate::domain::Event { id: format!("strategic:{revision}:actor:{}", action.actor_id), at, @@ -2326,7 +2369,6 @@ fn apply_strategic_tick_plan( .clone() }); let member_name = campaign.gestalt_members[&action.member_id].name.clone(); - rebase_member_migration(&mut next, &action)?; events.push(crate::domain::Event { id: format!("strategic:{revision}:member:{}", action.member_id), at, @@ -2342,13 +2384,28 @@ fn apply_strategic_tick_plan( public_channels: action.public_channels, }); } + let plan_digest = crate::legacy_transition::digest_serializable(&lowering_plan) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation = crate::legacy_transition::lower_strategic_wave( + campaign, + &lowering_plan, + &format!("strategic-wave:{plan_digest}"), + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))? + .map(|transition| { + let receipt = + crate::legacy_transition::apply_lowered_transition(&mut next, &transition, Utc::now()) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + Ok::<_, KernelError>((transition, receipt)) + }) + .transpose()?; for outcome in activity_outcomes { let (source_subject_id, locations, public_channels) = outcome_event_context .remove(&outcome.action_digest) .ok_or_else(|| { KernelError::Invalid("strategic outcome lost its activity context".into()) })?; - apply_strategic_outcome_effect(&mut next, &outcome.effect)?; let mut subject_ids = BTreeSet::from([source_subject_id]); collect_outcome_subject_ids(&outcome.effect, &mut subject_ids); let mut actor_ids = Vec::new(); @@ -2394,193 +2451,7 @@ fn apply_strategic_tick_plan( }); } *campaign = next; - Ok(events) -} - -fn apply_strategic_outcome_effect( - campaign: &mut Campaign, - effect: &crate::domain::StrategicOutcomeEffect, -) -> Result<(), KernelError> { - use crate::domain::StrategicOutcomeEffect; - match effect { - StrategicOutcomeEffect::NoMaterialChange { .. } => {} - StrategicOutcomeEffect::ResourceCreated { - owner_subject_id, - resource, - } => insert_subject_resource(campaign, owner_subject_id, resource)?, - StrategicOutcomeEffect::ResourceConsumed { - owner_subject_id, - resource, - } => remove_subject_resource(campaign, owner_subject_id, resource)?, - StrategicOutcomeEffect::ResourceTransferred { - from_subject_id, - to_subject_id, - resource, - } => { - remove_subject_resource(campaign, from_subject_id, resource)?; - insert_subject_resource(campaign, to_subject_id, resource)?; - } - StrategicOutcomeEffect::GestaltPressure { - gestalt_id, - pressure_additions, - pressure_resolutions, - } => { - let gestalt = campaign - .gestalts - .get_mut(gestalt_id) - .ok_or_else(|| KernelError::Invalid("outcome gestalt vanished".into()))?; - crate::resolution::validate_gestalt_pressure_transition( - &gestalt.pressures, - pressure_additions, - pressure_resolutions, - ) - .map_err(|error| KernelError::Invalid(error.to_string()))?; - let resolved = pressure_resolutions.iter().collect::>(); - gestalt - .pressures - .retain(|pressure| !resolved.contains(pressure)); - gestalt.pressures.extend(pressure_additions.iter().cloned()); - gestalt.version = gestalt.version.saturating_add(1); - } - StrategicOutcomeEffect::AgencyRelationShift { - relation_id, - strength_delta, - } => { - let relation = campaign - .agency_relations - .get_mut(relation_id) - .ok_or_else(|| KernelError::Invalid("outcome relation vanished".into()))?; - relation.strength = u8::try_from(i16::from(relation.strength) + strength_delta) - .map_err(|_| KernelError::Invalid("outcome relation shift overflowed".into()))?; - } - StrategicOutcomeEffect::MemberMemory { member_id, memory } => { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("outcome member vanished".into()))?; - if !member.memories.contains(memory) { - member.memories.push(memory.clone()); - } - member.version = member.version.saturating_add(1); - } - StrategicOutcomeEffect::MemberObligation { - member_id, - obligation, - } => { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("outcome member vanished".into()))?; - member.obligations.insert(obligation.clone()); - member.version = member.version.saturating_add(1); - } - StrategicOutcomeEffect::MemberRelationship { - member_id, - other_subject_id, - description, - } => { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("outcome member vanished".into()))?; - member - .relationships - .insert(other_subject_id.clone(), description.clone()); - member.version = member.version.saturating_add(1); - } - StrategicOutcomeEffect::KnowledgeLearned { - owner_subject_id, - fact_id, - } => { - let statement = campaign - .facts - .get(fact_id) - .map(|fact| fact.statement.clone()) - .ok_or_else(|| KernelError::Invalid("outcome fact vanished".into()))?; - if let Some(member_id) = owner_subject_id.strip_prefix("member:") { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("outcome member vanished".into()))?; - member.knowledge_removals.remove(&statement); - member.knowledge_additions.insert(statement); - member.version = member.version.saturating_add(1); - } else { - let gestalt = campaign.gestalts.get_mut(owner_subject_id).ok_or_else(|| { - KernelError::Invalid("outcome knowledge owner vanished".into()) - })?; - gestalt.shared_knowledge.insert(statement); - gestalt.version = gestalt.version.saturating_add(1); - } - } - } - Ok(()) -} - -fn insert_subject_resource( - campaign: &mut Campaign, - subject_id: &str, - resource: &str, -) -> Result<(), KernelError> { - if let Some(member_id) = subject_id.strip_prefix("member:") { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("resource member vanished".into()))?; - member.equipment.insert(resource.into()); - member.version = member.version.saturating_add(1); - return Ok(()); - } - if let Some(gestalt) = campaign.gestalts.get_mut(subject_id) { - gestalt.resources.insert(resource.into()); - gestalt.version = gestalt.version.saturating_add(1); - return Ok(()); - } - if let Some(institution) = campaign.institutions.get_mut(subject_id) { - institution.resources.push(resource.into()); - institution.resources.sort(); - institution.resources.dedup(); - return Ok(()); - } - if let Some(actor) = campaign.actors.get_mut(subject_id) { - actor.equipment.insert(resource.into()); - return Ok(()); - } - Err(KernelError::Invalid("resource recipient vanished".into())) -} - -fn remove_subject_resource( - campaign: &mut Campaign, - subject_id: &str, - resource: &str, -) -> Result<(), KernelError> { - let removed = if let Some(member_id) = subject_id.strip_prefix("member:") { - let member = campaign - .gestalt_members - .get_mut(member_id) - .ok_or_else(|| KernelError::Invalid("resource member vanished".into()))?; - let removed = member.equipment.remove(resource); - member.version = member.version.saturating_add(1); - removed - } else if let Some(gestalt) = campaign.gestalts.get_mut(subject_id) { - let removed = gestalt.resources.remove(resource); - gestalt.version = gestalt.version.saturating_add(1); - removed - } else if let Some(institution) = campaign.institutions.get_mut(subject_id) { - let previous = institution.resources.len(); - institution.resources.retain(|value| value != resource); - institution.resources.len() != previous - } else if let Some(actor) = campaign.actors.get_mut(subject_id) { - actor.equipment.remove(resource) - } else { - return Err(KernelError::Invalid("resource owner vanished".into())); - }; - if !removed { - return Err(KernelError::Invalid( - "outcome attempted to spend a missing resource".into(), - )); - } - Ok(()) + Ok(AppliedStrategicTickPlan { events, mutation }) } fn collect_outcome_subject_ids( @@ -2698,113 +2569,11 @@ fn agency_subject_name(campaign: &Campaign, subject_id: &str) -> Result Result<(), KernelError> { - let effective_capabilities = - crate::resolution::effective_member_capabilities(campaign, &action.member_id) - .map_err(|error| KernelError::Invalid(error.to_string()))?; - let effective_knowledge = - crate::resolution::effective_member_knowledge(campaign, &action.member_id) - .map_err(|error| KernelError::Invalid(error.to_string()))?; - let source_goals = campaign.gestalts[&action.source_gestalt_id].goals.clone(); - let destination = campaign.gestalts[&action.destination_gestalt_id].clone(); - let effective_goals = { - let member = &campaign.gestalt_members[&action.member_id]; - if member.goals.is_empty() { - source_goals - } else { - member.goals.clone() - } - }; - let member = campaign - .gestalt_members - .get_mut(&action.member_id) - .expect("member migration was validated"); - member.capability_additions = effective_capabilities - .difference(&destination.shared_capabilities) - .cloned() - .collect(); - member.capability_removals = destination - .shared_capabilities - .difference(&effective_capabilities) - .cloned() - .collect(); - member.knowledge_additions = effective_knowledge - .difference(&destination.shared_knowledge) - .cloned() - .collect(); - member.knowledge_removals = destination - .shared_knowledge - .difference(&effective_knowledge) - .cloned() - .collect(); - member.goals = if effective_goals == destination.goals { - vec![] - } else { - effective_goals - }; - member.gestalt_id = action.destination_gestalt_id.clone(); - member.last_location_id = Some(action.destination_location_id.clone()); - member.version = member.version.saturating_add(1); - campaign - .gestalts - .get_mut(&action.source_gestalt_id) - .expect("source gestalt was validated") - .version += 1; - campaign - .gestalts - .get_mut(&action.destination_gestalt_id) - .expect("destination gestalt was validated") - .version += 1; - Ok(()) -} - -fn deterministic_strategic_tick( - campaign: &mut Campaign, - tick_number: u64, -) -> Vec { - let mut events = Vec::new(); - for institution in campaign.institutions.values_mut() { - let summary = institution - .goals - .first() - .map(|goal| format!("{} advances its interest: {}", institution.name, goal)) - .unwrap_or_else(|| format!("{} consolidates its current position", institution.name)); - institution.posture = format!("acting after strategic tick {tick_number}"); - events.push(crate::domain::Event { - id: format!("strategic:{}:{}", campaign.revision + 1, institution.id), - at: campaign.world_time, - kind: "institution_action".into(), - summary, - actor_ids: vec![], - institution_ids: vec![institution.id.clone()], - gestalt_ids: vec![], - location_ids: vec![], - public_channels: vec![format!("institution:{}", institution.id)], - }); - } - for gestalt in campaign.gestalts.values_mut() { - gestalt.version += 1; - let summary = gestalt - .goals - .first() - .map(|goal| format!("{} collectively advances: {}", gestalt.name, goal)) - .unwrap_or_else(|| format!("{} carries on its shared routine", gestalt.name)); - events.push(crate::domain::Event { - id: format!("strategic:{}:gestalt:{}", campaign.revision + 1, gestalt.id), - at: campaign.world_time, - kind: "gestalt_action".into(), - summary, - actor_ids: vec![], - institution_ids: vec![], - gestalt_ids: vec![gestalt.id.clone()], - location_ids: vec![gestalt.home_location_id.clone()], - public_channels: vec![format!("gestalt:{}", gestalt.id)], - }); - } - events +fn deterministic_strategic_tick_plan() -> StrategicTickPlan { + // Deterministic time and clock obligations are lowered by the same wave + // transition. Without an admitted Persona wave there is no authority to + // invent actor or institution activity. + StrategicTickPlan::default() } fn validate_public_channels(channels: &[String]) -> Result<(), KernelError> { @@ -2905,6 +2674,10 @@ fn commit_strategic_tick( model_receipt_hash: Option, event_ids: Vec, resolution_wave: Option, + mutation: Option<( + crate::legacy_transition::LoweredLegacyTransition, + crate::transition::WorldMutationReceipt, + )>, ) -> Result { crate::resolution::ensure_agency_profiles(&mut campaign); let previous_revision = campaign.revision; @@ -2949,6 +2722,9 @@ fn commit_strategic_tick( &receipt, &strategic, resolution_wave.as_ref(), + mutation + .as_ref() + .map(|(transition, receipt)| (&transition.authority, &transition.batch, receipt)), ) .map_err(persist)?; Ok(CommandResult::Committed { campaign, receipt }) @@ -3010,6 +2786,49 @@ fn commit( Ok(CommandResult::Committed { campaign, receipt }) } +fn commit_mutation_transition( + store: &CampaignStore, + row: cultcache_legacy::CultCacheEnvelope, + mut campaign: Campaign, + kind: &str, + roll: Option, + transition: crate::legacy_transition::LoweredLegacyTransition, + mutation_receipt: crate::transition::WorldMutationReceipt, +) -> Result { + crate::resolution::ensure_agency_profiles(&mut campaign); + let previous_revision = campaign.revision; + campaign.revision += 1; + if mutation_receipt.previous_world_revision != previous_revision + || mutation_receipt.world_revision != campaign.revision + { + return Err(KernelError::Invalid( + "mutation receipt does not bind the campaign transition".into(), + )); + } + let receipt = WorldCommitReceipt { + schema: "ghostlight.world_commit_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision, + revision: campaign.revision, + command_kind: kind.into(), + committed_at: mutation_receipt.committed_at, + roll, + }; + store + .append_world_transition_with_mutation( + &row, + "ghostlight.campaign.v1", + &campaign, + &format!("{}-{}", campaign.id, campaign.revision), + &receipt, + &transition.authority, + &transition.batch, + &mutation_receipt, + ) + .map_err(persist)?; + Ok(CommandResult::Committed { campaign, receipt }) +} + fn load_campaign_membership( store: &CampaignStore, campaign_id: uuid::Uuid, @@ -3095,7 +2914,17 @@ fn commit_governed_time_advance( proposal_row: cultcache_legacy::CultCacheEnvelope, mut proposal: TimeAdvanceProposal, ) -> Result { - campaign.world_time += Duration::minutes(i64::from(proposal.minutes)); + let transition = crate::legacy_transition::lower_time_advance( + &campaign, + proposal.minutes, + crate::transition::MutationProcedure::Governance, + &proposal.id, + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = + crate::legacy_transition::apply_lowered_transition(&mut campaign, &transition, Utc::now()) + .map_err(|error| KernelError::Invalid(error.to_string()))?; campaign.last_player_activity = Utc::now(); campaign.away_ticks_processed = 0; campaign.pending_ticks = 0; @@ -3109,11 +2938,18 @@ fn commit_governed_time_advance( previous_revision, revision: campaign.revision, command_kind: "unanimous_time_advance".into(), - committed_at: Utc::now(), + committed_at: mutation_receipt.committed_at, roll: None, }; store - .commit_time_advance(&campaign_row, &campaign, &proposal_row, &proposal, &receipt) + .commit_time_advance( + &campaign_row, + &campaign, + &proposal_row, + &proposal, + &receipt, + (&transition.authority, &transition.batch, &mutation_receipt), + ) .map_err(persist)?; Ok(CommandResult::Committed { campaign, receipt }) } @@ -3140,23 +2976,32 @@ fn commit_governed_group_travel( let route_minutes = campaign .locations .get(&proposal.origin_location_id) - .and_then(|location| location.routes.get(&proposal.destination_location_id)) - .map(|route| route.travel_minutes) + .and_then(|location| { + location.routes.iter().find_map(|(route_id, route)| { + (route.destination_id == proposal.destination_location_id) + .then_some(route.travel_minutes) + }) + }) .ok_or_else(|| KernelError::Invalid("group-travel route no longer exists".into()))?; if route_minutes != proposal.travel_minutes { return Err(KernelError::Invalid( "group-travel route changed after proposal".into(), )); } - for actor_id in &active_actor_ids { - campaign - .actors - .get_mut(actor_id) - .expect("active actor was checked") - .location_id = proposal.destination_location_id.clone(); - } + let transition = crate::legacy_transition::lower_group_travel( + &campaign, + &active_actor_ids, + &proposal.origin_location_id, + &proposal.destination_location_id, + route_minutes, + &proposal.id, + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = + crate::legacy_transition::apply_lowered_transition(&mut campaign, &transition, Utc::now()) + .map_err(|error| KernelError::Invalid(error.to_string()))?; crate::resolution::ensure_agency_profiles(&mut campaign); - campaign.world_time += Duration::minutes(i64::from(route_minutes)); campaign.last_player_activity = Utc::now(); campaign.away_ticks_processed = 0; campaign.pending_ticks = 0; @@ -3186,11 +3031,18 @@ fn commit_governed_group_travel( previous_revision, revision: campaign.revision, command_kind: "unanimous_group_travel".into(), - committed_at: Utc::now(), + committed_at: mutation_receipt.committed_at, roll: None, }; store - .commit_group_travel(&campaign_row, &campaign, &proposal_row, &proposal, &receipt) + .commit_group_travel( + &campaign_row, + &campaign, + &proposal_row, + &proposal, + &receipt, + (&transition.authority, &transition.batch, &mutation_receipt), + ) .map_err(persist)?; Ok(CommandResult::Committed { campaign, receipt }) } @@ -4815,6 +4667,12 @@ mod tests { assert!(receipt.roll.is_some()); assert_eq!(store.keys("world_commit_receipt.v1").unwrap().len(), 1); assert_eq!(store.keys("roll_receipt.v1").unwrap().len(), 1); + 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] diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs new file mode 100644 index 0000000..f708998 --- /dev/null +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -0,0 +1,2308 @@ +//! Temporary aggregate-storage lowering for the world transition migration. +//! +//! `Campaign` remains the persisted aggregate until the component-store +//! migration closes. Legacy effect records may enter here as requirements +//! evidence, but this module lowers them into exact `WorldMutation` permits and +//! runs the canonical component reducer before projecting the accepted values +//! back into the aggregate row. It owns no admission policy of its own. + +use crate::{ + domain::{ + ActorReaction, Campaign, OutcomeBand, StrategicActivityOutcome, StrategicOutcomeEffect, + StrategicTickPlan, WorldEffectDelta, + }, + transition::*, +}; +use anyhow::{Result, anyhow}; +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug)] +pub struct LoweredLegacyTransition { + pub authority: MutationAuthorityEnvelope, + pub batch: WorldMutationBatch, +} + +pub fn lower_foreground_effect( + campaign: &Campaign, + acting_actor_id: &str, + effect: &WorldEffectDelta, + outcome: OutcomeBand, + procedure: MutationProcedure, + effect_ceiling: &str, + source_receipt_id: &str, + means_digest: Option, + intended_effect_digest: Option, + expires_at: DateTime, +) -> Result { + if !campaign.actors.contains_key(acting_actor_id) { + return Err(anyhow!("transition actor is unknown")); + } + let mutations = foreground_mutations(campaign, effect)?; + lower_exact_mutations( + campaign, + mutations, + procedure, + Some(actor_subject(acting_actor_id)), + MutationOutcomeBinding::Foreground(outcome), + None, + effect_ceiling, + source_receipt_id, + means_digest, + intended_effect_digest, + expires_at, + ) +} + +pub fn lower_time_advance( + campaign: &Campaign, + minutes: u32, + procedure: MutationProcedure, + source_receipt_id: &str, + expires_at: DateTime, +) -> Result { + lower_exact_mutations( + campaign, + vec![WorldMutation::AdvanceWorldTime { + campaign: campaign_subject(campaign), + minutes: i64::from(minutes), + }], + procedure, + None, + MutationOutcomeBinding::Deterministic, + None, + "Advance canonical world time by exactly the admitted duration without puppeting any actor.", + source_receipt_id, + Some(digest_serializable(&("wait", minutes))?), + Some(digest_serializable(&("world_time_minutes", minutes))?), + expires_at, + ) +} + +pub fn lower_group_travel( + campaign: &Campaign, + actor_ids: &BTreeSet, + origin_location_id: &str, + destination_location_id: &str, + travel_minutes: u32, + source_receipt_id: &str, + expires_at: DateTime, +) -> Result { + let route_id = exact_route_id(campaign, origin_location_id, destination_location_id)?; + let route_minutes = campaign + .locations + .get(origin_location_id) + .and_then(|location| location.routes.get(&route_id)) + .map(|route| route.travel_minutes) + .ok_or_else(|| anyhow!("group travel route vanished"))?; + if route_minutes != travel_minutes { + return Err(anyhow!( + "group travel duration does not match exact topology" + )); + } + let mut mutations = Vec::with_capacity(actor_ids.len() + 1); + for actor_id in actor_ids { + let actor = campaign + .actors + .get(actor_id) + .filter(|actor| actor.location_id == origin_location_id) + .ok_or_else(|| anyhow!("group travel actor is absent from the shared origin"))?; + mutations.push(WorldMutation::Relocate { + subject: actor_subject(&actor.id), + from_place: place_subject(origin_location_id), + to_place: place_subject(destination_location_id), + route_id: route_id.clone(), + }); + } + mutations.push(WorldMutation::AdvanceWorldTime { + campaign: campaign_subject(campaign), + minutes: i64::from(travel_minutes), + }); + lower_exact_mutations( + campaign, + mutations, + MutationProcedure::Governance, + None, + MutationOutcomeBinding::Deterministic, + None, + "Relocate every unanimously bound human actor along one exact route and advance time by that route's duration.", + source_receipt_id, + Some(digest_serializable(&( + actor_ids, + origin_location_id, + destination_location_id, + travel_minutes, + ))?), + Some(digest_serializable(&( + "group_occupancy", + destination_location_id, + ))?), + expires_at, + ) +} + +#[allow(clippy::too_many_arguments)] +fn lower_exact_mutations( + campaign: &Campaign, + mutations: Vec, + procedure: MutationProcedure, + source_subject: Option, + outcome: MutationOutcomeBinding, + resolution_epoch: Option, + effect_ceiling: &str, + source_receipt_id: &str, + means_digest: Option, + intended_effect_digest: Option, + expires_at: DateTime, +) -> Result { + let mut permits = Vec::with_capacity(mutations.len()); + let mut permitted = Vec::with_capacity(mutations.len()); + for (index, mutation) in mutations.into_iter().enumerate() { + let permit_id = format!("permit:{source_receipt_id}:{index}"); + permits.push(exact_mutation_permit(permit_id.clone(), &mutation)?); + permitted.push(PermittedWorldMutation { + permit_id, + mutation, + }); + } + let mut authority = MutationAuthorityEnvelope { + schema: "ghostlight.mutation_authority_envelope.v1".into(), + id: format!( + "authority:{}:{}:{}", + campaign.id, + campaign.revision, + short_digest(source_receipt_id) + ), + campaign_id: campaign.id, + world_revision: campaign.revision, + resolution_epoch, + procedure, + source_subject, + outcome, + effect_ceiling: effect_ceiling.into(), + permits, + authority_receipt_ids: BTreeSet::from([source_receipt_id.into()]), + expires_at, + digest: String::new(), + }; + authority.digest = envelope_digest(&authority)?; + let mut batch = WorldMutationBatch { + schema: "ghostlight.world_mutation_batch.v1".into(), + id: format!( + "batch:{}:{}:{}", + campaign.id, + campaign.revision, + short_digest(source_receipt_id) + ), + campaign_id: campaign.id, + expected_world_revision: campaign.revision, + expected_resolution_epoch: resolution_epoch, + authority_envelope_digest: authority.digest.clone(), + source_receipt_id: source_receipt_id.into(), + means_digest, + intended_effect_digest, + mutations: permitted, + digest: String::new(), + }; + batch.digest = mutation_batch_digest(&batch)?; + Ok(LoweredLegacyTransition { authority, batch }) +} + +pub fn apply_lowered_transition( + campaign: &mut Campaign, + transition: &LoweredLegacyTransition, + now: DateTime, +) -> Result { + let snapshot = component_snapshot(campaign)?; + let application = + apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?; + project_mutated_components(campaign, &application.state, &transition.batch)?; + Ok(application.receipt) +} + +pub fn lower_reaction_wave( + campaign: &Campaign, + witnessed_memory: &str, + reactions: &[ActorReaction], + source_receipt_id: &str, + expires_at: DateTime, +) -> Result { + let mut mutations = Vec::new(); + for reaction in reactions { + let actor = campaign + .actors + .get(&reaction.actor_id) + .ok_or_else(|| anyhow!("reaction actor is unknown"))?; + let subject = actor_subject(&reaction.actor_id); + if actor.memories.len() < 64 + && !actor.memories.iter().any(|value| value == witnessed_memory) + { + mutations.push(WorldMutation::ChangeMemory { + subject: subject.clone(), + operation: MemoryMutationOperation::Record, + memory_id: format!( + "memory:reaction:{}:{}", + campaign.revision + 1, + short_digest(&reaction.actor_id) + ), + event_id: Some(format!("reaction-wave:{}", campaign.revision + 1)), + summary: Some(witnessed_memory.into()), + }); + } + for condition in &reaction.private_delta.conditions_add { + mutations.push(WorldMutation::ChangeCondition { + subject: subject.clone(), + operation: if actor.conditions.contains(condition) { + ConditionMutationOperation::Alter + } else { + ConditionMutationOperation::Apply + }, + condition_id: condition.clone(), + description: Some(condition.clone()), + severity: None, + }); + } + for condition in &reaction.private_delta.conditions_remove { + mutations.push(WorldMutation::ChangeCondition { + subject: subject.clone(), + operation: ConditionMutationOperation::Clear, + condition_id: condition.clone(), + description: None, + severity: None, + }); + } + for goal in &reaction.private_delta.goals_add { + if actor.goals.contains(goal) { + continue; + } + mutations.push(WorldMutation::ChangeCommitment { + subject: subject.clone(), + operation: CommitmentMutationOperation::Create, + kind: CommitmentKind::Goal, + commitment_id: format!( + "goal:reaction:{}:{}", + campaign.revision + 1, + short_digest(goal) + ), + counterparty: None, + description: Some(goal.clone()), + }); + } + for (target_id, description) in &reaction.private_delta.relationship_updates { + let target = resolve_subject(campaign, target_id) + .ok_or_else(|| anyhow!("reaction relationship target is unknown"))?; + mutations.push(WorldMutation::ChangeRelationship { + source: subject.clone(), + target, + operation: if actor.relationships.contains_key(target_id) { + RelationshipMutationOperation::Alter + } else { + RelationshipMutationOperation::Create + }, + relationship_id: relationship_id(&reaction.actor_id, target_id), + description: Some(description.clone()), + strength_delta: None, + }); + } + } + lower_exact_mutations( + campaign, + mutations, + MutationProcedure::ReactionAppraisal, + None, + MutationOutcomeBinding::Deterministic, + None, + "Each exact reacting actor may update only their private memory, conditions, goals, and directed relationships.", + source_receipt_id, + Some(digest_serializable(reactions)?), + None, + expires_at, + ) +} + +pub fn lower_strategic_wave( + campaign: &Campaign, + plan: &StrategicTickPlan, + source_receipt_id: &str, + expires_at: DateTime, +) -> Result> { + let mut mutations = vec![WorldMutation::AdvanceWorldTime { + campaign: campaign_subject(campaign), + minutes: i64::from(campaign.tick_hours).saturating_mul(60), + }]; + for clock in campaign + .clocks + .values() + .filter(|clock| clock.progress < clock.threshold) + { + mutations.push(WorldMutation::ChangePressure { + pressure: pressure_subject(&clock.id), + owner: campaign_subject(campaign), + operation: PressureMutationOperation::Advance, + amount: Some(1), + label: None, + }); + } + for action in &plan.institution_actions { + mutations.push(WorldMutation::ChangePosture { + subject: institution_subject(&action.institution_id), + posture: action.posture.clone(), + }); + } + for action in &plan.gestalt_actions { + let owner = population_subject(&action.gestalt_id); + for label in &action.pressure_additions { + mutations.push(WorldMutation::ChangePressure { + pressure: gestalt_pressure_subject(&action.gestalt_id, label), + owner: owner.clone(), + operation: PressureMutationOperation::Create, + amount: Some(4), + label: Some(label.clone()), + }); + } + for label in &action.pressure_resolutions { + mutations.push(WorldMutation::ChangePressure { + pressure: gestalt_pressure_subject(&action.gestalt_id, label), + owner: owner.clone(), + operation: PressureMutationOperation::Resolve, + amount: None, + label: None, + }); + } + } + for action in &plan.gestalt_migrations { + let source = campaign + .gestalts + .get(&action.gestalt_id) + .ok_or_else(|| anyhow!("strategic population source is unknown"))?; + mutations.push(WorldMutation::Relocate { + subject: population_subject(&action.gestalt_id), + from_place: place_subject(&source.home_location_id), + to_place: place_subject(&action.destination_location_id), + route_id: exact_route_id( + campaign, + &source.home_location_id, + &action.destination_location_id, + )?, + }); + } + for action in &plan.actor_moves { + let actor = campaign + .actors + .get(&action.actor_id) + .ok_or_else(|| anyhow!("strategic movement actor is unknown"))?; + mutations.push(WorldMutation::Relocate { + subject: actor_subject(&action.actor_id), + from_place: place_subject(&actor.location_id), + to_place: place_subject(&action.destination_id), + route_id: exact_route_id(campaign, &actor.location_id, &action.destination_id)?, + }); + } + for action in &plan.member_migrations { + let member = campaign + .gestalt_members + .get(&action.member_id) + .ok_or_else(|| anyhow!("strategic member is unknown"))?; + let source = campaign + .gestalts + .get(&action.source_gestalt_id) + .ok_or_else(|| anyhow!("strategic member source population is unknown"))?; + let origin = member + .last_location_id + .as_deref() + .unwrap_or(&source.home_location_id); + let subject = actor_subject(&format!("member:{}", action.member_id)); + if origin != action.destination_location_id { + mutations.push(WorldMutation::Relocate { + subject: subject.clone(), + from_place: place_subject(origin), + to_place: place_subject(&action.destination_location_id), + route_id: exact_route_id(campaign, origin, &action.destination_location_id)?, + }); + } + mutations.push(WorldMutation::ChangePopulationMembership { + actor: subject, + operation: PopulationMembershipOperation::Transfer, + source_population: Some(population_subject(&action.source_gestalt_id)), + destination_population: Some(population_subject(&action.destination_gestalt_id)), + }); + } + + let mut bindings = Vec::with_capacity(plan.activity_outcomes.len()); + for outcome in &plan.activity_outcomes { + bindings.push(StrategicOutcomeSourceBinding { + action_digest: outcome.action_digest.clone(), + band: outcome.band.clone(), + }); + push_strategic_outcome_mutations(campaign, outcome, &mut mutations)?; + } + if mutations.is_empty() { + return Ok(None); + } + Ok(Some(lower_exact_mutations( + campaign, + mutations, + MutationProcedure::StrategicOutcome, + None, + MutationOutcomeBinding::StrategicWave(bindings), + Some(campaign.resolution_policy.resolution_epoch), + "The strategic wave may commit only the exact admitted movements, posture changes, population transitions, and constituent-attributed consequences selected by its resolution receipt.", + source_receipt_id, + Some(digest_serializable(plan)?), + None, + expires_at, + )?)) +} + +fn push_strategic_outcome_mutations( + campaign: &Campaign, + outcome: &StrategicActivityOutcome, + mutations: &mut Vec, +) -> Result<()> { + match &outcome.effect { + StrategicOutcomeEffect::NoMaterialChange { .. } => {} + StrategicOutcomeEffect::ResourceCreated { + owner_subject_id, + resource, + } => { + let owner = resolve_subject(campaign, owner_subject_id) + .ok_or_else(|| anyhow!("strategic resource owner is unknown"))?; + mutations.push(WorldMutation::MutateResource { + resource: resource_subject(campaign, resource), + operation: ResourceMutationOperation::Create, + custodian: Some(owner), + related_resources: Vec::new(), + resource_kind: Some("legacy_named_resource".into()), + resource_label: Some(resource.clone()), + recipe_id: None, + quantity: Some(1), + integrity: Some(100), + }); + } + StrategicOutcomeEffect::ResourceConsumed { + owner_subject_id, + resource, + } => { + let owner = resolve_subject(campaign, owner_subject_id) + .ok_or_else(|| anyhow!("strategic resource owner is unknown"))?; + mutations.push(WorldMutation::MutateResource { + resource: resource_subject(campaign, resource), + operation: ResourceMutationOperation::Consume, + custodian: Some(owner), + related_resources: Vec::new(), + resource_kind: None, + resource_label: None, + recipe_id: None, + quantity: Some(1), + integrity: None, + }); + } + StrategicOutcomeEffect::ResourceTransferred { + from_subject_id, + to_subject_id, + resource, + } => { + let from = resolve_subject(campaign, from_subject_id) + .ok_or_else(|| anyhow!("strategic resource source is unknown"))?; + let to = resolve_subject(campaign, to_subject_id) + .ok_or_else(|| anyhow!("strategic resource recipient is unknown"))?; + mutations.push(WorldMutation::TransferCustody { + resource: resource_subject(campaign, resource), + from_custodian: from, + to_custodian: to, + }); + } + StrategicOutcomeEffect::GestaltPressure { + gestalt_id, + pressure_additions, + pressure_resolutions, + } => { + let owner = population_subject(gestalt_id); + if !campaign.gestalts.contains_key(gestalt_id) { + return Err(anyhow!("strategic pressure owner is unknown")); + } + for label in pressure_additions { + mutations.push(WorldMutation::ChangePressure { + pressure: gestalt_pressure_subject(gestalt_id, label), + owner: owner.clone(), + operation: PressureMutationOperation::Create, + amount: Some(4), + label: Some(label.clone()), + }); + } + for label in pressure_resolutions { + mutations.push(WorldMutation::ChangePressure { + pressure: gestalt_pressure_subject(gestalt_id, label), + owner: owner.clone(), + operation: PressureMutationOperation::Resolve, + amount: None, + label: None, + }); + } + } + StrategicOutcomeEffect::AgencyRelationShift { + relation_id, + strength_delta, + } => { + let relation = campaign + .agency_relations + .get(relation_id) + .ok_or_else(|| anyhow!("strategic agency relation is unknown"))?; + let source = resolve_subject(campaign, &relation.from_subject_id) + .ok_or_else(|| anyhow!("strategic relation source is unknown"))?; + let target = resolve_subject(campaign, &relation.to_subject_id) + .ok_or_else(|| anyhow!("strategic relation target is unknown"))?; + mutations.push(WorldMutation::ChangeRelationship { + source, + target, + operation: RelationshipMutationOperation::Alter, + relationship_id: relation_id.clone(), + description: None, + strength_delta: Some(i64::from(*strength_delta)), + }); + } + StrategicOutcomeEffect::MemberMemory { member_id, memory } => { + ensure_member(campaign, member_id)?; + mutations.push(WorldMutation::ChangeMemory { + subject: actor_subject(&format!("member:{member_id}")), + operation: MemoryMutationOperation::Record, + memory_id: format!( + "memory:strategic:{}:{}", + campaign.revision + 1, + short_digest(memory) + ), + event_id: Some(outcome.action_digest.clone()), + summary: Some(memory.clone()), + }); + } + StrategicOutcomeEffect::MemberObligation { + member_id, + obligation, + } => { + ensure_member(campaign, member_id)?; + mutations.push(WorldMutation::ChangeCommitment { + subject: actor_subject(&format!("member:{member_id}")), + operation: CommitmentMutationOperation::Create, + kind: CommitmentKind::Obligation, + commitment_id: format!( + "obligation:strategic:{}:{}", + campaign.revision + 1, + short_digest(obligation) + ), + counterparty: None, + description: Some(obligation.clone()), + }); + } + StrategicOutcomeEffect::MemberRelationship { + member_id, + other_subject_id, + description, + } => { + 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}")); + mutations.push(WorldMutation::ChangeRelationship { + source: source.clone(), + target, + operation: if member.relationships.contains_key(other_subject_id) { + RelationshipMutationOperation::Alter + } else { + RelationshipMutationOperation::Create + }, + relationship_id: relationship_id(&source.id, other_subject_id), + description: Some(description.clone()), + strength_delta: None, + }); + } + StrategicOutcomeEffect::KnowledgeLearned { + owner_subject_id, + fact_id, + } => { + let knower = resolve_subject(campaign, owner_subject_id) + .ok_or_else(|| anyhow!("strategic knowledge owner is unknown"))?; + if !campaign.facts.contains_key(fact_id) { + return Err(anyhow!("strategic proposition is unknown")); + } + mutations.push(WorldMutation::ChangeKnowledge { + operation: KnowledgeMutationOperation::Acquire, + proposition: proposition_subject(fact_id), + knower: Some(knower), + speaker: None, + recipients: Vec::new(), + channel: None, + }); + } + } + Ok(()) +} + +fn exact_route_id(campaign: &Campaign, origin: &str, destination: &str) -> Result { + campaign + .locations + .get(origin) + .and_then(|location| { + location.routes.iter().find_map(|(route_id, route)| { + (route.destination_id == destination).then(|| route_id.clone()) + }) + }) + .ok_or_else(|| anyhow!("strategic relocation has no exact topology edge")) +} + +pub fn digest_serializable(value: &T) -> Result { + let bytes = rmp_serde::to_vec_named(value)?; + Ok(format!("sha256:{:x}", Sha256::digest(bytes))) +} + +fn foreground_mutations( + campaign: &Campaign, + effect: &WorldEffectDelta, +) -> Result> { + let mut mutations = Vec::new(); + for (actor_id, delta) in &effect.actor_conditions { + let actor = campaign + .actors + .get(actor_id) + .ok_or_else(|| anyhow!("outcome actor vanished"))?; + for condition in &delta.add { + mutations.push(WorldMutation::ChangeCondition { + subject: actor_subject(actor_id), + operation: if actor.conditions.contains(condition) { + ConditionMutationOperation::Alter + } else { + ConditionMutationOperation::Apply + }, + condition_id: condition.clone(), + description: Some(condition.clone()), + severity: None, + }); + } + for condition in &delta.remove { + mutations.push(WorldMutation::ChangeCondition { + subject: actor_subject(actor_id), + operation: ConditionMutationOperation::Clear, + condition_id: condition.clone(), + description: None, + severity: None, + }); + } + } + for (actor_id, additions) in &effect.actor_knowledge_additions { + if !campaign.actors.contains_key(actor_id) { + return Err(anyhow!("outcome actor vanished")); + } + for statement in additions { + let fact = campaign + .facts + .values() + .find(|fact| fact.statement == *statement) + .ok_or_else(|| anyhow!("outcome knowledge has no canonical proposition"))?; + mutations.push(WorldMutation::ChangeKnowledge { + operation: KnowledgeMutationOperation::Acquire, + proposition: proposition_subject(&fact.id), + knower: Some(actor_subject(actor_id)), + speaker: None, + recipients: Vec::new(), + channel: None, + }); + } + } + for (actor_id, relationships) in &effect.actor_relationship_updates { + let actor = campaign + .actors + .get(actor_id) + .ok_or_else(|| anyhow!("outcome actor vanished"))?; + for (target_id, description) in relationships { + let target = resolve_subject(campaign, target_id) + .ok_or_else(|| anyhow!("relationship target vanished"))?; + mutations.push(WorldMutation::ChangeRelationship { + source: actor_subject(actor_id), + target, + operation: if actor.relationships.contains_key(target_id) { + RelationshipMutationOperation::Alter + } else { + RelationshipMutationOperation::Create + }, + relationship_id: relationship_id(actor_id, target_id), + description: Some(description.clone()), + strength_delta: None, + }); + } + } + for (actor_id, destination_id) in &effect.actor_moves { + let actor = campaign + .actors + .get(actor_id) + .ok_or_else(|| anyhow!("outcome actor vanished"))?; + let location = campaign + .locations + .get(&actor.location_id) + .ok_or_else(|| anyhow!("actor origin vanished"))?; + let route_id = location + .routes + .iter() + .find_map(|(route_id, route)| { + (route.destination_id == *destination_id).then(|| route_id.clone()) + }) + .ok_or_else(|| anyhow!("outcome movement has no exact route"))?; + mutations.push(WorldMutation::Relocate { + subject: actor_subject(actor_id), + from_place: place_subject(&actor.location_id), + to_place: place_subject(destination_id), + route_id, + }); + } + let campaign_subject = campaign_subject(campaign); + for (clock_id, amount) in &effect.clock_advances { + mutations.push(WorldMutation::ChangePressure { + pressure: pressure_subject(clock_id), + owner: campaign_subject.clone(), + operation: PressureMutationOperation::Advance, + amount: Some(i64::from(*amount)), + label: None, + }); + } + for (clock_id, amount) in &effect.clock_reductions { + mutations.push(WorldMutation::ChangePressure { + pressure: pressure_subject(clock_id), + owner: campaign_subject.clone(), + operation: PressureMutationOperation::Reduce, + amount: Some(i64::from(*amount)), + label: None, + }); + } + for (institution_id, posture) in &effect.institution_postures { + if !campaign.institutions.contains_key(institution_id) { + return Err(anyhow!("outcome institution vanished")); + } + mutations.push(WorldMutation::ChangePosture { + subject: institution_subject(institution_id), + posture: posture.clone(), + }); + } + Ok(mutations) +} + +fn component_snapshot(campaign: &Campaign) -> Result { + let version = campaign.revision; + let mut state = ComponentWorldState { + schema: "ghostlight.component_world_state.v1".into(), + campaign_id: campaign.id, + revision: campaign.revision, + resolution_epoch: campaign.resolution_policy.resolution_epoch, + world_time: campaign.world_time, + subjects: BTreeMap::new(), + occupancy: BTreeMap::new(), + custody: BTreeMap::new(), + resources: BTreeMap::new(), + capabilities: BTreeMap::new(), + conditions: BTreeMap::new(), + commitments: BTreeMap::new(), + relationships: BTreeMap::new(), + pressures: BTreeMap::new(), + knowledge: BTreeMap::new(), + memories: BTreeMap::new(), + postures: BTreeMap::new(), + memberships: BTreeMap::new(), + population_lineages: BTreeMap::new(), + identities: BTreeMap::new(), + topology: BTreeMap::new(), + }; + admit( + &mut state, + campaign_subject(campaign), + BTreeSet::from([WorldComponentKind::WorldTime]), + version, + ); + for location in campaign.locations.values() { + admit( + &mut state, + place_subject(&location.id), + BTreeSet::from([WorldComponentKind::Topology]), + version, + ); + } + for actor in campaign.actors.values() { + let subject = actor_subject(&actor.id); + admit( + &mut state, + subject.clone(), + BTreeSet::from([ + WorldComponentKind::Identity, + WorldComponentKind::Occupancy, + WorldComponentKind::Capability, + WorldComponentKind::Condition, + WorldComponentKind::Knowledge, + WorldComponentKind::Memory, + WorldComponentKind::Relationship, + WorldComponentKind::Commitment, + ]), + version, + ); + state + .occupancy + .insert(subject.clone(), place_subject(&actor.location_id)); + state.identities.insert( + format!("identity:canonical:{}", actor.id), + IdentityHandleState { + schema: "ghostlight.identity_handle.v1".into(), + id: format!("identity:canonical:{}", actor.id), + subject: subject.clone(), + value: actor.name.clone(), + active: true, + known_by: BTreeSet::from([subject.clone()]), + restricted_to: BTreeSet::new(), + source_revision: version, + }, + ); + for capability in &actor.capabilities { + state.capabilities.insert( + entry_key(&subject, capability), + CapabilityComponentState { + description: capability.clone(), + suspended: false, + version, + }, + ); + } + for condition in &actor.conditions { + state.conditions.insert( + entry_key(&subject, condition), + ConditionComponentState { + description: condition.clone(), + severity: None, + version, + }, + ); + } + for (index, goal) in actor.goals.iter().enumerate() { + state.commitments.insert( + entry_key(&subject, &format!("goal:{index}:{}", short_digest(goal))), + CommitmentComponentState { + kind: CommitmentKind::Goal, + description: goal.clone(), + counterparty: None, + status: "active".into(), + version, + }, + ); + } + for obligation in &actor.obligations { + state.commitments.insert( + entry_key( + &subject, + &format!("obligation:{}", short_digest(obligation)), + ), + CommitmentComponentState { + kind: CommitmentKind::Obligation, + description: obligation.clone(), + counterparty: None, + status: "active".into(), + version, + }, + ); + } + for (index, memory) in actor.memories.iter().enumerate() { + state.memories.insert( + entry_key( + &subject, + &format!("memory:{index}:{}", short_digest(memory)), + ), + MemoryComponentState { + event_id: None, + summary: memory.clone(), + version, + }, + ); + } + } + for institution in campaign.institutions.values() { + let subject = institution_subject(&institution.id); + admit( + &mut state, + subject.clone(), + BTreeSet::from([ + WorldComponentKind::Identity, + WorldComponentKind::Posture, + WorldComponentKind::Commitment, + ]), + version, + ); + state.postures.insert(subject, institution.posture.clone()); + } + for gestalt in campaign.gestalts.values() { + let subject = population_subject(&gestalt.id); + admit( + &mut state, + subject.clone(), + BTreeSet::from([ + WorldComponentKind::Identity, + WorldComponentKind::Occupancy, + WorldComponentKind::Capability, + WorldComponentKind::Knowledge, + WorldComponentKind::Pressure, + ]), + version, + ); + state + .occupancy + .insert(subject.clone(), place_subject(&gestalt.home_location_id)); + state.identities.insert( + format!("identity:canonical:{}", gestalt.id), + IdentityHandleState { + schema: "ghostlight.identity_handle.v1".into(), + id: format!("identity:canonical:{}", gestalt.id), + subject: subject.clone(), + value: gestalt.name.clone(), + active: true, + known_by: BTreeSet::from([subject.clone()]), + restricted_to: BTreeSet::new(), + source_revision: version, + }, + ); + for capability in &gestalt.shared_capabilities { + state.capabilities.insert( + entry_key(&subject, capability), + CapabilityComponentState { + description: capability.clone(), + suspended: false, + version, + }, + ); + } + for (index, goal) in gestalt.goals.iter().enumerate() { + state.commitments.insert( + entry_key(&subject, &format!("goal:{index}:{}", short_digest(goal))), + CommitmentComponentState { + kind: CommitmentKind::Goal, + description: goal.clone(), + counterparty: None, + status: "active".into(), + version, + }, + ); + } + for statement in &gestalt.shared_knowledge { + let proposition = ensure_proposition(&mut state, campaign, statement, version); + state.knowledge.insert( + KnowledgeKey { + knower: subject.clone(), + proposition, + }, + KnowledgeComponentState { + status: "known".into(), + source: None, + channel: None, + concealed_from: BTreeSet::new(), + version, + }, + ); + } + for pressure_label in &gestalt.pressures { + let pressure = gestalt_pressure_subject(&gestalt.id, pressure_label); + admit( + &mut state, + pressure.clone(), + BTreeSet::from([WorldComponentKind::Pressure]), + version, + ); + state.pressures.insert( + pressure.clone(), + PressureComponentState { + pressure, + owner: subject.clone(), + label: pressure_label.clone(), + progress: 0, + threshold: 4, + resolved: false, + version, + }, + ); + } + } + for member in campaign.gestalt_members.values() { + let subject = actor_subject(&format!("member:{}", member.id)); + let gestalt = campaign + .gestalts + .get(&member.gestalt_id) + .ok_or_else(|| anyhow!("gestalt member baseline vanished"))?; + admit( + &mut state, + subject.clone(), + BTreeSet::from([ + WorldComponentKind::Identity, + WorldComponentKind::Occupancy, + WorldComponentKind::Capability, + WorldComponentKind::Condition, + WorldComponentKind::Knowledge, + WorldComponentKind::Memory, + WorldComponentKind::Relationship, + WorldComponentKind::Commitment, + WorldComponentKind::PopulationMembership, + ]), + version, + ); + state.occupancy.insert( + subject.clone(), + place_subject( + member + .last_location_id + .as_deref() + .unwrap_or(&gestalt.home_location_id), + ), + ); + state + .identities + .entry(format!("identity:canonical:member:{}", member.id)) + .or_insert_with(|| IdentityHandleState { + schema: "ghostlight.identity_handle.v1".into(), + id: format!("identity:canonical:member:{}", member.id), + subject: subject.clone(), + value: member.name.clone(), + active: true, + known_by: BTreeSet::from([subject.clone()]), + restricted_to: BTreeSet::new(), + source_revision: version, + }); + state.memberships.insert( + MembershipKey { + actor: subject.clone(), + population: population_subject(&member.gestalt_id), + }, + PopulationMembershipState { + active: true, + version, + }, + ); + for capability in &member.capability_additions { + state.capabilities.insert( + entry_key(&subject, capability), + CapabilityComponentState { + description: capability.clone(), + suspended: false, + version, + }, + ); + } + for condition in &member.conditions { + state.conditions.insert( + entry_key(&subject, condition), + ConditionComponentState { + description: condition.clone(), + severity: None, + version, + }, + ); + } + for obligation in &member.obligations { + state.commitments.insert( + entry_key( + &subject, + &format!("obligation:{}", short_digest(obligation)), + ), + CommitmentComponentState { + kind: CommitmentKind::Obligation, + description: obligation.clone(), + counterparty: None, + status: "active".into(), + version, + }, + ); + } + for (index, goal) in member.goals.iter().enumerate() { + state.commitments.insert( + entry_key(&subject, &format!("goal:{index}:{}", short_digest(goal))), + CommitmentComponentState { + kind: CommitmentKind::Goal, + description: goal.clone(), + counterparty: None, + status: "active".into(), + version, + }, + ); + } + for (index, memory) in member.memories.iter().enumerate() { + state.memories.insert( + entry_key( + &subject, + &format!("memory:{index}:{}", short_digest(memory)), + ), + MemoryComponentState { + event_id: None, + summary: memory.clone(), + version, + }, + ); + } + for statement in &member.knowledge_additions { + let proposition = ensure_proposition(&mut state, campaign, statement, version); + state.knowledge.insert( + KnowledgeKey { + knower: subject.clone(), + proposition, + }, + KnowledgeComponentState { + status: "known".into(), + source: None, + channel: None, + concealed_from: BTreeSet::new(), + version, + }, + ); + } + for (target_id, description) in &member.relationships { + let Some(target) = resolve_subject(campaign, target_id) else { + continue; + }; + let id = relationship_id(&subject.id, target_id); + state.relationships.insert( + id, + RelationshipComponentState { + source: subject.clone(), + target, + description: description.clone(), + strength: None, + version, + }, + ); + } + } + for relation in campaign + .agency_relations + .values() + .filter(|relation| relation.active) + { + let Some(source) = resolve_subject(campaign, &relation.from_subject_id) else { + continue; + }; + let Some(target) = resolve_subject(campaign, &relation.to_subject_id) else { + continue; + }; + state.relationships.insert( + relation.id.clone(), + RelationshipComponentState { + source, + target, + description: format!("{:?}", relation.kind).to_lowercase(), + strength: Some(i64::from(relation.strength)), + version, + }, + ); + } + for fact in campaign.facts.values() { + admit( + &mut state, + proposition_subject(&fact.id), + BTreeSet::from([WorldComponentKind::Knowledge]), + version, + ); + } + for clock in campaign.clocks.values() { + let pressure = pressure_subject(&clock.id); + admit( + &mut state, + pressure.clone(), + BTreeSet::from([WorldComponentKind::Pressure]), + version, + ); + state.pressures.insert( + pressure.clone(), + PressureComponentState { + pressure, + owner: campaign_subject(campaign), + label: clock.label.clone(), + progress: i64::from(clock.progress), + threshold: i64::from(clock.threshold), + resolved: clock.progress >= clock.threshold, + version, + }, + ); + } + for location in campaign.locations.values() { + for (route_id, route) in &location.routes { + state.topology.insert( + route_id.clone(), + TopologyComponentState { + id: route_id.clone(), + from_place: place_subject(&location.id), + to_place: place_subject(&route.destination_id), + travel_minutes: i64::from(route.travel_minutes), + open: true, + version, + }, + ); + } + } + for actor in campaign.actors.values() { + let knower = actor_subject(&actor.id); + for statement in &actor.knowledge { + let proposition = if let Some(fact) = campaign + .facts + .values() + .find(|fact| fact.statement == *statement) + { + proposition_subject(&fact.id) + } else { + let proposition = + proposition_subject(&format!("legacy-knowledge:{}", short_digest(statement))); + admit( + &mut state, + proposition.clone(), + BTreeSet::from([WorldComponentKind::Knowledge]), + version, + ); + proposition + }; + state.knowledge.insert( + KnowledgeKey { + knower: knower.clone(), + proposition, + }, + KnowledgeComponentState { + status: "known".into(), + source: None, + channel: None, + concealed_from: BTreeSet::new(), + version, + }, + ); + } + for (target_id, description) in &actor.relationships { + let Some(target) = resolve_subject(campaign, target_id) else { + continue; + }; + let id = relationship_id(&actor.id, target_id); + state.relationships.insert( + id, + RelationshipComponentState { + source: knower.clone(), + target, + description: description.clone(), + strength: None, + version, + }, + ); + } + } + for (label, owners) in legacy_resource_owners(campaign) { + if owners.len() != 1 { + return Err(anyhow!( + "legacy resource label {label:?} has ambiguous custody; normalize it before mutation" + )); + } + let owner = owners.into_iter().next().expect("one owner checked"); + let resource = resource_subject(campaign, &label); + admit( + &mut state, + resource.clone(), + BTreeSet::from([ + WorldComponentKind::ResourceState, + WorldComponentKind::Custody, + ]), + version, + ); + state.custody.insert(resource.clone(), owner); + state.resources.insert( + resource.clone(), + ResourceComponentState { + schema: "ghostlight.resource_component.v1".into(), + resource, + resource_kind: "legacy_named_resource".into(), + label, + quantity: 1, + integrity: 100, + qualities: BTreeSet::new(), + version, + }, + ); + } + validate_component_world(&state)?; + Ok(state) +} + +fn project_mutated_components( + campaign: &mut Campaign, + next: &ComponentWorldState, + batch: &WorldMutationBatch, +) -> Result<()> { + let mut resources_changed = false; + let mut resource_owners_changed = BTreeSet::new(); + for permitted in &batch.mutations { + match &permitted.mutation { + WorldMutation::TransferCustody { + from_custodian, + to_custodian, + .. + } => { + resources_changed = true; + resource_owners_changed.insert(from_custodian.clone()); + resource_owners_changed.insert(to_custodian.clone()); + } + WorldMutation::MutateResource { custodian, .. } => { + resources_changed = true; + resource_owners_changed.extend(custodian.iter().cloned()); + } + WorldMutation::Relocate { subject, .. } if subject.kind == SubjectKind::Actor => { + let destination = next + .occupancy + .get(subject) + .ok_or_else(|| anyhow!("accepted relocation lost occupancy"))?; + if let Some(member_id) = subject.id.strip_prefix("member:") { + let member = campaign + .gestalt_members + .get_mut(member_id) + .ok_or_else(|| anyhow!("accepted relocation member vanished"))?; + member.last_location_id = Some(destination.id.clone()); + if let Some(actor) = campaign.actors.get_mut(&subject.id) { + actor.location_id = destination.id.clone(); + } + } else { + campaign + .actors + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted relocation actor vanished"))? + .location_id = destination.id.clone(); + } + } + WorldMutation::Relocate { subject, .. } if subject.kind == SubjectKind::Population => { + let destination = next + .occupancy + .get(subject) + .ok_or_else(|| anyhow!("accepted population relocation lost occupancy"))?; + let gestalt = campaign + .gestalts + .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); + let profile = campaign + .agency_profiles + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted relocation agency profile vanished"))?; + profile.location_ids = BTreeSet::from([destination.id.clone()]); + profile.profile_version = profile.profile_version.saturating_add(1); + } + WorldMutation::ChangeCondition { + subject, + condition_id, + .. + } if subject.kind == SubjectKind::Actor => { + let actor = campaign + .actors + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted condition actor vanished"))?; + let key = entry_key(subject, condition_id); + if next.conditions.contains_key(&key) { + actor.conditions.insert(condition_id.clone()); + } else { + actor.conditions.remove(condition_id); + } + } + WorldMutation::ChangeKnowledge { + proposition, + knower, + recipients, + .. + } => { + let statement = campaign + .facts + .get(&proposition.id) + .map(|fact| fact.statement.clone()) + .ok_or_else(|| anyhow!("accepted proposition lost its world fact"))?; + for subject in knower.iter().chain(recipients) { + if !next.knowledge.contains_key(&KnowledgeKey { + knower: subject.clone(), + proposition: proposition.clone(), + }) { + continue; + } + match subject.kind { + SubjectKind::Actor => { + if let Some(member_id) = subject.id.strip_prefix("member:") { + let member = campaign + .gestalt_members + .get_mut(member_id) + .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); + if let Some(actor) = campaign.actors.get_mut(&subject.id) { + actor.knowledge.insert(statement.clone()); + } + } else { + campaign + .actors + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted knowledge actor vanished"))? + .knowledge + .insert(statement.clone()); + } + } + SubjectKind::Population => { + let gestalt = campaign + .gestalts + .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); + } + _ => { + return Err(anyhow!( + "aggregate knowledge projection lacks subject kind {:?}", + subject.kind + )); + } + } + } + } + WorldMutation::ChangeRelationship { + relationship_id, .. + } if campaign.agency_relations.contains_key(relationship_id) => { + let value = next + .relationships + .get(relationship_id) + .ok_or_else(|| anyhow!("accepted agency relation vanished"))?; + let strength = value + .strength + .ok_or_else(|| anyhow!("accepted agency relation lost strength"))?; + campaign + .agency_relations + .get_mut(relationship_id) + .expect("existence checked") + .strength = u8::try_from(strength) + .map_err(|_| anyhow!("accepted agency strength exceeded storage"))?; + } + WorldMutation::ChangeRelationship { + source, + target, + relationship_id, + .. + } if source.kind == SubjectKind::Actor => { + if let Some(member_id) = source.id.strip_prefix("member:") { + let member = campaign + .gestalt_members + .get_mut(member_id) + .ok_or_else(|| anyhow!("accepted relationship member vanished"))?; + if let Some(value) = next.relationships.get(relationship_id) { + member + .relationships + .insert(target.id.clone(), value.description.clone()); + if let Some(actor) = campaign.actors.get_mut(&source.id) { + actor + .relationships + .insert(target.id.clone(), value.description.clone()); + } + } else { + member.relationships.remove(&target.id); + if let Some(actor) = campaign.actors.get_mut(&source.id) { + actor.relationships.remove(&target.id); + } + } + member.version = member.version.saturating_add(1); + } else { + let actor = campaign + .actors + .get_mut(&source.id) + .ok_or_else(|| anyhow!("accepted relationship actor vanished"))?; + if let Some(value) = next.relationships.get(relationship_id) { + actor + .relationships + .insert(target.id.clone(), value.description.clone()); + } else { + actor.relationships.remove(&target.id); + } + } + } + WorldMutation::ChangeMemory { + subject, memory_id, .. + } if subject.kind == SubjectKind::Actor => { + let key = entry_key(subject, memory_id); + let value = next + .memories + .get(&key) + .ok_or_else(|| anyhow!("accepted memory vanished"))?; + if let Some(member_id) = subject.id.strip_prefix("member:") { + let member = campaign + .gestalt_members + .get_mut(member_id) + .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); + } + if let Some(actor) = campaign.actors.get_mut(&subject.id) { + if actor.memories.len() < 64 && !actor.memories.contains(&value.summary) { + actor.memories.push(value.summary.clone()); + } + } + } else { + let actor = campaign + .actors + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted memory actor vanished"))?; + if actor.memories.len() < 64 && !actor.memories.contains(&value.summary) { + actor.memories.push(value.summary.clone()); + } + } + } + WorldMutation::ChangeCommitment { + subject, + kind, + commitment_id, + .. + } if subject.kind == SubjectKind::Actor => { + let key = entry_key(subject, commitment_id); + let value = next.commitments.get(&key); + if let Some(member_id) = subject.id.strip_prefix("member:") { + let member = campaign + .gestalt_members + .get_mut(member_id) + .ok_or_else(|| anyhow!("accepted commitment member vanished"))?; + match (kind, value) { + (CommitmentKind::Goal, Some(value)) => { + if !member.goals.contains(&value.description) { + member.goals.push(value.description.clone()); + } + } + (CommitmentKind::Obligation, Some(value)) => { + member.obligations.insert(value.description.clone()); + } + (CommitmentKind::Goal, None) => member + .goals + .retain(|goal| !campaign_commitment_matches(goal, commitment_id)), + (CommitmentKind::Obligation, None) => { + member.obligations.retain(|obligation| { + !campaign_commitment_matches(obligation, commitment_id) + }); + } + } + member.version = member.version.saturating_add(1); + if let Some(actor) = campaign.actors.get_mut(&subject.id) { + match (kind, value) { + (CommitmentKind::Goal, Some(value)) => { + if !actor.goals.contains(&value.description) { + actor.goals.push(value.description.clone()); + } + } + (CommitmentKind::Obligation, Some(value)) => { + actor.obligations.insert(value.description.clone()); + } + (CommitmentKind::Goal, None) => actor + .goals + .retain(|goal| !campaign_commitment_matches(goal, commitment_id)), + (CommitmentKind::Obligation, None) => { + actor.obligations.retain(|obligation| { + !campaign_commitment_matches(obligation, commitment_id) + }); + } + } + } + } else { + let actor = campaign + .actors + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted commitment actor vanished"))?; + match (kind, value) { + (CommitmentKind::Goal, Some(value)) => { + if !actor.goals.contains(&value.description) { + actor.goals.push(value.description.clone()); + } + } + (CommitmentKind::Obligation, Some(value)) => { + actor.obligations.insert(value.description.clone()); + } + (CommitmentKind::Goal, None) => actor + .goals + .retain(|goal| !campaign_commitment_matches(goal, commitment_id)), + (CommitmentKind::Obligation, None) => { + actor.obligations.retain(|obligation| { + !campaign_commitment_matches(obligation, commitment_id) + }); + } + } + } + } + WorldMutation::ChangePressure { pressure, .. } + if pressure.kind == SubjectKind::Pressure => + { + let value = next + .pressures + .get(pressure) + .ok_or_else(|| anyhow!("accepted pressure vanished"))?; + if let Some(clock) = campaign.clocks.get_mut(&pressure.id) { + clock.progress = u8::try_from(value.progress) + .map_err(|_| anyhow!("accepted clock exceeded aggregate storage"))?; + } else if value.owner.kind == SubjectKind::Population { + let gestalt = campaign + .gestalts + .get_mut(&value.owner.id) + .ok_or_else(|| anyhow!("accepted pressure gestalt vanished"))?; + gestalt.pressures.retain(|label| label != &value.label); + if !value.resolved { + gestalt.pressures.push(value.label.clone()); + } + gestalt.version = gestalt.version.saturating_add(1); + } else { + return Err(anyhow!("aggregate pressure owner is unsupported")); + } + } + WorldMutation::ChangePosture { subject, .. } + if subject.kind == SubjectKind::Institution => + { + let posture = next + .postures + .get(subject) + .ok_or_else(|| anyhow!("accepted posture vanished"))?; + campaign + .institutions + .get_mut(&subject.id) + .ok_or_else(|| anyhow!("accepted institution vanished"))? + .posture = posture.clone(); + } + WorldMutation::ChangePopulationMembership { + actor, + operation: PopulationMembershipOperation::Transfer, + source_population: Some(source), + destination_population: Some(destination), + } if actor.kind == SubjectKind::Actor + && source.kind == SubjectKind::Population + && destination.kind == SubjectKind::Population => + { + let member_id = actor.id.strip_prefix("member:").ok_or_else(|| { + anyhow!("aggregate membership transfer requires a named member") + })?; + let source_key = MembershipKey { + actor: actor.clone(), + population: source.clone(), + }; + let destination_key = MembershipKey { + actor: actor.clone(), + population: destination.clone(), + }; + if next + .memberships + .get(&source_key) + .is_some_and(|membership| membership.active) + || !next + .memberships + .get(&destination_key) + .is_some_and(|membership| membership.active) + { + return Err(anyhow!( + "accepted population transfer has incoherent final membership" + )); + } + project_member_population_transfer( + campaign, + member_id, + &source.id, + &destination.id, + )?; + } + WorldMutation::AdvanceWorldTime { .. } => { + campaign.world_time = next.world_time; + } + _ => { + return Err(anyhow!( + "aggregate projection does not yet support mutation {:?}", + permitted.mutation.operation() + )); + } + } + } + if resources_changed { + project_all_resources(campaign, next, &resource_owners_changed)?; + } + Ok(()) +} + +fn campaign_commitment_matches(value: &str, commitment_id: &str) -> bool { + commitment_id.ends_with(&short_digest(value)) +} + +fn project_member_population_transfer( + campaign: &mut Campaign, + member_id: &str, + source_population_id: &str, + destination_population_id: &str, +) -> Result<()> { + let member = campaign + .gestalt_members + .get(member_id) + .filter(|member| { + member.materialized_actor_id.is_none() && member.gestalt_id == source_population_id + }) + .ok_or_else(|| anyhow!("accepted member transfer has stale aggregate source"))?; + let source = campaign + .gestalts + .get(source_population_id) + .ok_or_else(|| anyhow!("accepted member transfer source vanished"))?; + let destination = campaign + .gestalts + .get(destination_population_id) + .ok_or_else(|| anyhow!("accepted member transfer destination vanished"))? + .clone(); + + // The member's exact effective state survives a change of aggregate + // baseline. Only its delta representation is recomputed. + let effective_capabilities = source + .shared_capabilities + .union(&member.capability_additions) + .filter(|value| !member.capability_removals.contains(*value)) + .cloned() + .collect::>(); + let effective_knowledge = source + .shared_knowledge + .union(&member.knowledge_additions) + .filter(|value| !member.knowledge_removals.contains(*value)) + .cloned() + .collect::>(); + let effective_goals = if member.goals.is_empty() { + source.goals.clone() + } else { + member.goals.clone() + }; + + let member = campaign + .gestalt_members + .get_mut(member_id) + .expect("member source was resolved"); + member.capability_additions = effective_capabilities + .difference(&destination.shared_capabilities) + .cloned() + .collect(); + member.capability_removals = destination + .shared_capabilities + .difference(&effective_capabilities) + .cloned() + .collect(); + member.knowledge_additions = effective_knowledge + .difference(&destination.shared_knowledge) + .cloned() + .collect(); + member.knowledge_removals = destination + .shared_knowledge + .difference(&effective_knowledge) + .cloned() + .collect(); + member.goals = if effective_goals == destination.goals { + Vec::new() + } else { + 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<()> { + for actor in campaign.actors.values_mut() { + actor.equipment.clear(); + } + for member in campaign.gestalt_members.values_mut() { + member.equipment.clear(); + } + for institution in campaign.institutions.values_mut() { + institution.resources.clear(); + } + for gestalt in campaign.gestalts.values_mut() { + gestalt.resources.clear(); + } + for (resource, value) in &next.resources { + let owner = next + .custody + .get(resource) + .ok_or_else(|| anyhow!("accepted resource lost custody"))?; + match owner.kind { + SubjectKind::Actor => { + if let Some(member_id) = owner.id.strip_prefix("member:") { + campaign + .gestalt_members + .get_mut(member_id) + .ok_or_else(|| anyhow!("accepted resource member vanished"))? + .equipment + .insert(value.label.clone()); + if let Some(actor) = campaign.actors.get_mut(&owner.id) { + actor.equipment.insert(value.label.clone()); + } + } else { + campaign + .actors + .get_mut(&owner.id) + .ok_or_else(|| anyhow!("accepted resource actor vanished"))? + .equipment + .insert(value.label.clone()); + } + } + SubjectKind::Institution => campaign + .institutions + .get_mut(&owner.id) + .ok_or_else(|| anyhow!("accepted resource institution vanished"))? + .resources + .push(value.label.clone()), + SubjectKind::Population => { + campaign + .gestalts + .get_mut(&owner.id) + .ok_or_else(|| anyhow!("accepted resource gestalt vanished"))? + .resources + .insert(value.label.clone()); + } + _ => 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(); + } + Ok(()) +} + +fn admit( + state: &mut ComponentWorldState, + subject: SubjectRef, + admitted_components: BTreeSet, + version: u64, +) { + if let Some(existing) = state.subjects.get_mut(&subject) { + existing.admitted_components.extend(admitted_components); + existing.version = existing.version.max(version); + } else { + state.subjects.insert( + subject.clone(), + TypedSubject { + schema: "ghostlight.typed_subject.v1".into(), + subject, + lifecycle: LifecycleStatus::Active, + admitted_components, + version, + }, + ); + } +} + +fn resolve_subject(campaign: &Campaign, id: &str) -> Option { + if campaign.actors.contains_key(id) { + Some(actor_subject(id)) + } else if id + .strip_prefix("member:") + .is_some_and(|member_id| campaign.gestalt_members.contains_key(member_id)) + { + Some(actor_subject(id)) + } else if campaign.institutions.contains_key(id) { + Some(institution_subject(id)) + } else if campaign.gestalts.contains_key(id) { + Some(population_subject(id)) + } else if campaign.locations.contains_key(id) { + Some(place_subject(id)) + } else if campaign.facts.contains_key(id) { + Some(proposition_subject(id)) + } else { + None + } +} + +fn ensure_member<'a>( + campaign: &'a Campaign, + member_id: &str, +) -> Result<&'a crate::domain::GestaltMemberDelta> { + campaign + .gestalt_members + .get(member_id) + .ok_or_else(|| anyhow!("strategic member is unknown")) +} + +fn entry_key(subject: &SubjectRef, entry_id: &str) -> SubjectEntryKey { + SubjectEntryKey { + subject: subject.clone(), + entry_id: entry_id.into(), + } +} + +fn ensure_proposition( + state: &mut ComponentWorldState, + campaign: &Campaign, + statement: &str, + version: u64, +) -> SubjectRef { + let proposition = campaign + .facts + .values() + .find(|fact| fact.statement == statement) + .map(|fact| proposition_subject(&fact.id)) + .unwrap_or_else(|| { + proposition_subject(&format!("legacy-knowledge:{}", short_digest(statement))) + }); + admit( + state, + proposition.clone(), + BTreeSet::from([WorldComponentKind::Knowledge]), + version, + ); + proposition +} + +fn legacy_resource_owners(campaign: &Campaign) -> BTreeMap> { + let mut resources = BTreeMap::>::new(); + for actor in campaign.actors.values() { + for resource in &actor.equipment { + resources + .entry(resource.clone()) + .or_default() + .insert(actor_subject(&actor.id)); + } + } + for institution in campaign.institutions.values() { + for resource in &institution.resources { + resources + .entry(resource.clone()) + .or_default() + .insert(institution_subject(&institution.id)); + } + } + for gestalt in campaign.gestalts.values() { + for resource in &gestalt.resources { + resources + .entry(resource.clone()) + .or_default() + .insert(population_subject(&gestalt.id)); + } + } + for member in campaign.gestalt_members.values() { + let owner = actor_subject(&format!("member:{}", member.id)); + for resource in &member.equipment { + resources + .entry(resource.clone()) + .or_default() + .insert(owner.clone()); + } + } + resources +} + +fn relationship_id(source: &str, target: &str) -> String { + format!( + "relationship:{}", + short_digest(&(source.to_owned() + "\0" + target)) + ) +} + +fn campaign_subject(campaign: &Campaign) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Campaign, + id: campaign.id.to_string(), + } +} + +fn actor_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Actor, + id: id.into(), + } +} + +fn population_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Population, + id: id.into(), + } +} + +fn institution_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Institution, + id: id.into(), + } +} + +fn place_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Place, + id: id.into(), + } +} + +fn pressure_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Pressure, + id: id.into(), + } +} + +fn gestalt_pressure_subject(gestalt_id: &str, label: &str) -> SubjectRef { + pressure_subject(&format!( + "gestalt-pressure:{}:{}", + gestalt_id, + short_digest(label) + )) +} + +fn resource_subject(campaign: &Campaign, label: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Resource, + id: format!( + "resource:{}", + short_digest(&(campaign.id.to_string() + "\0" + label)) + ), + } +} + +fn proposition_subject(id: &str) -> SubjectRef { + SubjectRef { + kind: SubjectKind::Proposition, + id: id.into(), + } +} + +fn short_digest(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) + .chars() + .take(16) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{ + ActorState, BranchOrigin, ConditionDelta, InstitutionState, Location, ResolutionPolicy, + Route, WorldClock, WorldFact, + }; + use chrono::Duration; + use uuid::Uuid; + + fn campaign() -> Campaign { + let id = Uuid::new_v4(); + Campaign { + schema: "ghostlight.campaign.v1".into(), + id, + name: "Transition fixture".into(), + revision: 4, + branch_origin: BranchOrigin { + canon_cutoff: "fixture".into(), + evidence_receipt_ids: vec![], + }, + world_time: Utc::now(), + tick_hours: 6, + player_actor_id: "player".into(), + locations: BTreeMap::from([ + ( + "room".into(), + Location { + id: "room".into(), + name: "Room".into(), + container_id: None, + routes: BTreeMap::from([( + "door".into(), + Route { + destination_id: "hall".into(), + distance: "nearby".into(), + travel_minutes: 2, + }, + )]), + persistent_features: vec![], + }, + ), + ( + "hall".into(), + Location { + id: "hall".into(), + name: "Hall".into(), + container_id: None, + routes: BTreeMap::new(), + persistent_features: vec![], + }, + ), + ]), + actors: BTreeMap::from([ + ( + "player".into(), + ActorState { + id: "player".into(), + name: "Player".into(), + location_id: "room".into(), + capabilities: BTreeSet::new(), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + }, + ), + ( + "witness".into(), + ActorState { + id: "witness".into(), + name: "Witness".into(), + location_id: "room".into(), + capabilities: BTreeSet::new(), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + }, + ), + ]), + institutions: BTreeMap::from([( + "watch".into(), + InstitutionState { + id: "watch".into(), + name: "Watch".into(), + resources: vec![], + goals: vec![], + posture: "guarded".into(), + }, + )]), + clocks: BTreeMap::from([( + "alarm".into(), + WorldClock { + id: "alarm".into(), + label: "Alarm".into(), + progress: 2, + threshold: 6, + consequence: "The doors lock.".into(), + }, + )]), + facts: BTreeMap::from([( + "fact:route".into(), + WorldFact { + id: "fact:route".into(), + statement: "The west stair bypasses the checkpoint.".into(), + scope: crate::domain::FactScope::BranchLocal, + evidence_receipt_ids: vec![], + discoverable_at_location_ids: BTreeSet::from(["room".into()]), + }, + )]), + transcript: vec![], + last_player_activity: Utc::now(), + pending_ticks: 0, + away_ticks_processed: 0, + events: vec![], + news: vec![], + canon_candidates: BTreeMap::new(), + gestalts: BTreeMap::new(), + gestalt_members: BTreeMap::new(), + pending_world_proposals: vec![], + agency_profiles: BTreeMap::new(), + agency_relations: BTreeMap::new(), + gestalt_lineages: BTreeMap::new(), + resolution_policy: ResolutionPolicy::default(), + resolution_pins: BTreeMap::new(), + resolution_cover: None, + strategic_tick_count: 0, + } + } + + #[test] + fn foreground_legacy_record_can_only_change_state_through_exact_mutations() { + let mut campaign = campaign(); + let effect = WorldEffectDelta { + actor_conditions: BTreeMap::from([( + "player".into(), + ConditionDelta { + add: BTreeSet::from(["winded".into()]), + remove: BTreeSet::new(), + }, + )]), + actor_knowledge_additions: BTreeMap::from([( + "player".into(), + BTreeSet::from(["The west stair bypasses the checkpoint.".into()]), + )]), + actor_relationship_updates: BTreeMap::from([( + "player".into(), + BTreeMap::from([("witness".into(), "owes a candid answer".into())]), + )]), + actor_moves: BTreeMap::from([("player".into(), "hall".into())]), + clock_advances: BTreeMap::from([("alarm".into(), 1)]), + clock_reductions: BTreeMap::new(), + institution_postures: BTreeMap::from([("watch".into(), "searching".into())]), + }; + let transition = lower_foreground_effect( + &campaign, + "player", + &effect, + OutcomeBand::Success, + MutationProcedure::ForegroundAttempt, + "The player reaches the hall but raises the alarm.", + "assessment:test", + None, + None, + Utc::now() + Duration::minutes(5), + ) + .unwrap(); + assert_eq!(transition.batch.mutations.len(), 6); + let receipt = apply_lowered_transition(&mut campaign, &transition, Utc::now()).unwrap(); + assert_eq!(receipt.previous_world_revision, 4); + assert_eq!(receipt.world_revision, 5); + assert_eq!(campaign.actors["player"].location_id, "hall"); + assert!(campaign.actors["player"].conditions.contains("winded")); + assert!( + campaign.actors["player"] + .knowledge + .contains("The west stair bypasses the checkpoint.") + ); + assert_eq!( + campaign.actors["player"].relationships["witness"], + "owes a candid answer" + ); + assert_eq!(campaign.clocks["alarm"].progress, 3); + assert_eq!(campaign.institutions["watch"].posture, "searching"); + } + + #[test] + fn stale_or_tampered_lowering_cannot_partially_project() { + let mut campaign = campaign(); + let before = campaign.clone(); + let effect = WorldEffectDelta { + actor_moves: BTreeMap::from([("player".into(), "hall".into())]), + ..WorldEffectDelta::default() + }; + let mut transition = lower_foreground_effect( + &campaign, + "player", + &effect, + OutcomeBand::Success, + MutationProcedure::ForegroundAttempt, + "The player reaches the hall.", + "assessment:test", + None, + None, + Utc::now() + Duration::minutes(5), + ) + .unwrap(); + let WorldMutation::Relocate { to_place, .. } = &mut transition.batch.mutations[0].mutation + else { + unreachable!(); + }; + to_place.id = "forbidden".into(); + transition.batch.digest = mutation_batch_digest(&transition.batch).unwrap(); + assert!(apply_lowered_transition(&mut campaign, &transition, Utc::now()).is_err()); + assert_eq!(campaign, before); + } +} diff --git a/crates/ghostlight-dungeon/src/lib.rs b/crates/ghostlight-dungeon/src/lib.rs index d7e8915..afb644b 100644 --- a/crates/ghostlight-dungeon/src/lib.rs +++ b/crates/ghostlight-dungeon/src/lib.rs @@ -9,6 +9,7 @@ pub mod gestalt; pub mod idunn_health; pub mod initiative; pub mod kernel; +pub mod legacy_transition; pub mod mesh; pub mod model; pub mod narrator; diff --git a/crates/ghostlight-dungeon/src/persistence.rs b/crates/ghostlight-dungeon/src/persistence.rs index 87f3c4b..7effbdf 100644 --- a/crates/ghostlight-dungeon/src/persistence.rs +++ b/crates/ghostlight-dungeon/src/persistence.rs @@ -357,6 +357,72 @@ impl CampaignStore { Ok(next_row) } + pub fn append_world_transition_with_mutation( + &self, + expected: &CultCacheEnvelope, + next_schema: &str, + next: &T, + receipt_key: &str, + receipt: &WorldCommitReceipt, + authority: &MutationAuthorityEnvelope, + batch: &WorldMutationBatch, + mutation_receipt: &WorldMutationReceipt, + ) -> Result { + if mutation_receipt.previous_world_revision != receipt.previous_revision + || mutation_receipt.world_revision != receipt.revision + || mutation_receipt.campaign_id != receipt.campaign_id + || mutation_receipt.batch_digest != batch.digest + || mutation_receipt.authority_envelope_digest != authority.digest + { + return Err(anyhow!( + "world and mutation receipts do not describe one transition" + )); + } + let next_row = envelope(&expected.r#type, next_schema, &expected.key, next)?; + let mut rows = vec![ + next_row.clone(), + envelope( + "world_commit_receipt.v1", + "ghostlight.world_commit_receipt.v1", + receipt_key, + receipt, + )?, + envelope( + "mutation_authority_envelope.v1", + "ghostlight.mutation_authority_envelope.v1", + &authority.id, + authority, + )?, + envelope( + "world_mutation_batch.v1", + "ghostlight.world_mutation_batch.v1", + &batch.id, + batch, + )?, + envelope( + "world_mutation_receipt.v1", + "ghostlight.world_mutation_receipt.v1", + &mutation_receipt.id, + mutation_receipt, + )?, + ]; + if let Some(roll) = &receipt.roll { + rows.push(envelope( + "roll_receipt.v1", + "ghostlight.roll_receipt.v1", + &roll.assessment_digest, + roll, + )?); + } + if !self + .inner + .compare_and_swap_batch(std::slice::from_ref(expected), rows)? + { + return Err(anyhow!("stale CultCache snapshot")); + } + Ok(next_row) + } + pub fn commit_time_advance( &self, expected_campaign: &CultCacheEnvelope, @@ -364,6 +430,11 @@ impl CampaignStore { expected_proposal: &CultCacheEnvelope, next_proposal: &TimeAdvanceProposal, receipt: &WorldCommitReceipt, + mutation: ( + &MutationAuthorityEnvelope, + &WorldMutationBatch, + &WorldMutationReceipt, + ), ) -> Result { let next_campaign_row = envelope( &expected_campaign.r#type, @@ -377,7 +448,7 @@ impl CampaignStore { &expected_proposal.key, next_proposal, )?; - let rows = vec![ + let mut rows = vec![ next_campaign_row.clone(), next_proposal_row, envelope( @@ -387,6 +458,7 @@ impl CampaignStore { receipt, )?, ]; + append_mutation_proof(&mut rows, receipt, mutation)?; if !self.inner.compare_and_swap_batch( &[expected_campaign.clone(), expected_proposal.clone()], rows, @@ -403,6 +475,11 @@ impl CampaignStore { expected_proposal: &CultCacheEnvelope, next_proposal: &GroupTravelProposal, receipt: &WorldCommitReceipt, + mutation: ( + &MutationAuthorityEnvelope, + &WorldMutationBatch, + &WorldMutationReceipt, + ), ) -> Result { let next_campaign_row = envelope( &expected_campaign.r#type, @@ -416,7 +493,7 @@ impl CampaignStore { &expected_proposal.key, next_proposal, )?; - let rows = vec![ + let mut rows = vec![ next_campaign_row.clone(), next_proposal_row, envelope( @@ -426,6 +503,7 @@ impl CampaignStore { receipt, )?, ]; + append_mutation_proof(&mut rows, receipt, mutation)?; if !self.inner.compare_and_swap_batch( &[expected_campaign.clone(), expected_proposal.clone()], rows, @@ -644,6 +722,11 @@ impl CampaignStore { world_receipt: &WorldCommitReceipt, strategic_receipt: &StrategicTickReceipt, resolution_wave: Option<&ResolutionWaveCommit>, + mutation: Option<( + &MutationAuthorityEnvelope, + &WorldMutationBatch, + &WorldMutationReceipt, + )>, ) -> Result { let next_row = envelope( &expected.r#type, @@ -706,6 +789,35 @@ impl CampaignStore { )?); } } + if let Some((authority, batch, mutation_receipt)) = mutation { + if mutation_receipt.previous_world_revision != world_receipt.previous_revision + || mutation_receipt.world_revision != world_receipt.revision + || mutation_receipt.batch_digest != batch.digest + || mutation_receipt.authority_envelope_digest != authority.digest + { + return Err(anyhow!( + "strategic and mutation receipts do not describe one transition" + )); + } + rows.push(envelope( + "mutation_authority_envelope.v1", + "ghostlight.mutation_authority_envelope.v1", + &authority.id, + authority, + )?); + rows.push(envelope( + "world_mutation_batch.v1", + "ghostlight.world_mutation_batch.v1", + &batch.id, + batch, + )?); + rows.push(envelope( + "world_mutation_receipt.v1", + "ghostlight.world_mutation_receipt.v1", + &mutation_receipt.id, + mutation_receipt, + )?); + } if !self .inner .compare_and_swap_batch(std::slice::from_ref(expected), rows)? @@ -784,6 +896,46 @@ impl CampaignStore { } } +fn append_mutation_proof( + rows: &mut Vec, + world_receipt: &WorldCommitReceipt, + (authority, batch, mutation_receipt): ( + &MutationAuthorityEnvelope, + &WorldMutationBatch, + &WorldMutationReceipt, + ), +) -> Result<()> { + if mutation_receipt.previous_world_revision != world_receipt.previous_revision + || mutation_receipt.world_revision != world_receipt.revision + || mutation_receipt.campaign_id != world_receipt.campaign_id + || mutation_receipt.batch_digest != batch.digest + || mutation_receipt.authority_envelope_digest != authority.digest + { + return Err(anyhow!( + "world and mutation receipts do not describe one transition" + )); + } + rows.push(envelope( + "mutation_authority_envelope.v1", + "ghostlight.mutation_authority_envelope.v1", + &authority.id, + authority, + )?); + rows.push(envelope( + "world_mutation_batch.v1", + "ghostlight.world_mutation_batch.v1", + &batch.id, + batch, + )?); + rows.push(envelope( + "world_mutation_receipt.v1", + "ghostlight.world_mutation_receipt.v1", + &mutation_receipt.id, + mutation_receipt, + )?); + Ok(()) +} + fn merge_vault_manifest( existing: Option<&VaultManifest>, receipts: &[VaultEvidenceReceipt], diff --git a/crates/ghostlight-dungeon/src/transition.rs b/crates/ghostlight-dungeon/src/transition.rs index 0c3eb85..e67c0a2 100644 --- a/crates/ghostlight-dungeon/src/transition.rs +++ b/crates/ghostlight-dungeon/src/transition.rs @@ -100,6 +100,7 @@ pub struct MutationIntent { #[serde(rename_all = "snake_case")] pub enum MutationProcedure { ForegroundAttempt, + DirectCommand, NpcAttempt, ReactionAppraisal, StrategicOutcome, @@ -113,9 +114,16 @@ pub enum MutationProcedure { pub enum MutationOutcomeBinding { Foreground(OutcomeBand), Strategic(StrategicOutcomeBand), + StrategicWave(Vec), Deterministic, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct StrategicOutcomeSourceBinding { + pub action_digest: String, + pub band: StrategicOutcomeBand, +} + #[derive( Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, Hash, )] @@ -217,7 +225,11 @@ pub enum MutationSubjectRole { pub enum MutationStringRole { RouteId, ResourceKind, + ResourceLabel, RecipeId, + Description, + Summary, + RetirementReason, CapabilityId, ConditionId, CommitmentId, @@ -240,6 +252,7 @@ pub enum MutationIntegerRole { Quantity, Integrity, Severity, + RelationshipStrengthDelta, PressureAmount, TravelMinutes, WorldMinutes, @@ -436,6 +449,7 @@ pub enum WorldMutation { #[serde(default)] related_resources: Vec, resource_kind: Option, + resource_label: Option, recipe_id: Option, quantity: Option, integrity: Option, @@ -471,6 +485,7 @@ pub enum WorldMutation { relationship_id: String, #[schemars(length(max = 1000))] description: Option, + strength_delta: Option, }, ChangePressure { pressure: SubjectRef, @@ -627,6 +642,7 @@ pub struct ResourceComponentState { pub schema: String, pub resource: SubjectRef, pub resource_kind: String, + pub label: String, pub quantity: i64, pub integrity: i64, #[serde(default)] @@ -668,6 +684,7 @@ pub struct RelationshipComponentState { pub source: SubjectRef, pub target: SubjectRef, pub description: String, + pub strength: Option, pub version: u64, } @@ -940,13 +957,22 @@ fn apply_component_mutation( subject, from_place, to_place, - .. + route_id, } => { active_subject(state, subject)?; active_subject(state, from_place)?; active_subject(state, to_place)?; require_kind(from_place, SubjectKind::Place)?; require_kind(to_place, SubjectKind::Place)?; + let route = state + .topology + .get(route_id) + .ok_or_else(|| anyhow!("relocation route does not exist"))?; + if !route.open || route.from_place != *from_place || route.to_place != *to_place { + return Err(anyhow!( + "relocation route does not admit the exact origin and destination" + )); + } if state.occupancy.get(subject) != Some(from_place) { return Err(anyhow!( "relocation origin does not match canonical occupancy" @@ -974,6 +1000,7 @@ fn apply_component_mutation( custodian, related_resources, resource_kind, + resource_label, recipe_id, quantity, integrity, @@ -984,6 +1011,7 @@ fn apply_component_mutation( custodian.as_ref(), related_resources, resource_kind.as_deref(), + resource_label.as_deref(), recipe_id.as_deref(), *quantity, *integrity, @@ -1112,6 +1140,7 @@ fn apply_component_mutation( operation, relationship_id, description, + strength_delta, } => apply_relationship_mutation( state, source, @@ -1119,6 +1148,7 @@ fn apply_component_mutation( operation, relationship_id, description.as_deref(), + *strength_delta, )?, ChangePressure { pressure, @@ -1253,12 +1283,28 @@ fn apply_resource_mutation( custodian: Option<&SubjectRef>, related_resources: &[SubjectRef], resource_kind: Option<&str>, + resource_label: Option<&str>, recipe_id: Option<&str>, quantity: Option, integrity: Option, ) -> Result<()> { require_kind(resource, SubjectKind::Resource)?; let version = next_component_version(state); + if !matches!(operation, ResourceMutationOperation::Create) { + let custodian = + custodian.ok_or_else(|| anyhow!("resource mutation lacks exact custody authority"))?; + active_subject(state, custodian)?; + if matches!(operation, ResourceMutationOperation::Combine) { + if related_resources + .iter() + .any(|input| state.custody.get(input) != Some(custodian)) + { + return Err(anyhow!("resource combination exceeds exact custody")); + } + } else if state.custody.get(resource) != Some(custodian) { + return Err(anyhow!("resource mutation exceeds exact custody")); + } + } match operation { ResourceMutationOperation::Create => { if state.subjects.contains_key(resource) || state.resources.contains_key(resource) { @@ -1273,6 +1319,9 @@ fn apply_resource_mutation( let resource_kind = resource_kind .filter(|value| !value.trim().is_empty()) .ok_or_else(|| anyhow!("resource creation lacks a kind"))?; + let resource_label = resource_label + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("resource creation lacks a label"))?; state.subjects.insert( resource.clone(), TypedSubject { @@ -1293,6 +1342,7 @@ fn apply_resource_mutation( schema: "ghostlight.resource_component.v1".into(), resource: resource.clone(), resource_kind: resource_kind.into(), + label: resource_label.into(), quantity, integrity: integrity.unwrap_or(100), qualities: BTreeSet::new(), @@ -1304,11 +1354,7 @@ fn apply_resource_mutation( ResourceMutationOperation::Transform => { let _recipe = recipe_id.ok_or_else(|| anyhow!("resource transformation lacks recipe"))?; - let owner = state - .custody - .get(resource) - .cloned() - .ok_or_else(|| anyhow!("resource has no custodian"))?; + let owner = custodian.expect("non-create custody checked").clone(); for input in related_resources { active_subject(state, input)?; require_kind(input, SubjectKind::Resource)?; @@ -1326,6 +1372,9 @@ fn apply_resource_mutation( if let Some(kind) = resource_kind { value.resource_kind = kind.into(); } + if let Some(label) = resource_label { + value.label = label.into(); + } if quantity.is_some_and(|quantity| quantity != value.quantity) { return Err(anyhow!( "resource transformation cannot alter quantity; compose consumption or creation" @@ -1390,11 +1439,7 @@ fn apply_resource_mutation( if state.subjects.contains_key(child) { return Err(anyhow!("resource split child already exists")); } - let owner = state - .custody - .get(resource) - .cloned() - .ok_or_else(|| anyhow!("resource has no custodian"))?; + let owner = custodian.expect("non-create custody checked").clone(); let source = state .resources .get_mut(resource) @@ -1410,6 +1455,7 @@ fn apply_resource_mutation( schema: source.schema.clone(), resource: child.clone(), resource_kind: source.resource_kind.clone(), + label: resource_label.unwrap_or(&source.label).into(), quantity: amount, integrity: source.integrity, qualities: source.qualities.clone(), @@ -1443,11 +1489,7 @@ fn apply_resource_mutation( let first = related_resources .first() .expect("length checked before first"); - let owner = state - .custody - .get(first) - .cloned() - .ok_or_else(|| anyhow!("combined input has no custodian"))?; + let owner = custodian.expect("non-create custody checked").clone(); let first_state = state .resources .get(first) @@ -1495,6 +1537,7 @@ fn apply_resource_mutation( schema: first_state.schema, resource: resource.clone(), resource_kind: resource_kind.unwrap_or(&first_state.resource_kind).into(), + label: resource_label.unwrap_or(&first_state.label).into(), quantity: total, integrity: integrity.unwrap_or(first_state.integrity), qualities: first_state.qualities, @@ -1598,6 +1641,7 @@ fn apply_relationship_mutation( operation: &RelationshipMutationOperation, relationship_id: &str, description: Option<&str>, + strength_delta: Option, ) -> Result<()> { active_subject(state, source)?; active_subject(state, target)?; @@ -1607,6 +1651,9 @@ fn apply_relationship_mutation( if state.relationships.contains_key(relationship_id) { return Err(anyhow!("relationship already exists")); } + if strength_delta.is_some_and(|value| !(0..=100).contains(&value)) { + return Err(anyhow!("relationship strength exceeds bounds")); + } state.relationships.insert( relationship_id.into(), RelationshipComponentState { @@ -1616,6 +1663,7 @@ fn apply_relationship_mutation( .filter(|value| !value.trim().is_empty()) .ok_or_else(|| anyhow!("relationship creation lacks a description"))? .into(), + strength: strength_delta, version, }, ); @@ -1628,10 +1676,22 @@ fn apply_relationship_mutation( if value.source != *source || value.target != *target { return Err(anyhow!("relationship endpoints do not match")); } - value.description = description - .filter(|description| !description.trim().is_empty()) - .ok_or_else(|| anyhow!("relationship alteration lacks a description"))? - .into(); + if let Some(description) = description.filter(|value| !value.trim().is_empty()) { + value.description = description.into(); + } + if let Some(delta) = strength_delta { + let current = value + .strength + .ok_or_else(|| anyhow!("relationship has no numeric strength"))?; + let next = current + .checked_add(delta) + .filter(|next| (0..=100).contains(next)) + .ok_or_else(|| anyhow!("relationship strength exceeds bounds"))?; + value.strength = Some(next); + } + if description.is_none() && strength_delta.is_none() { + return Err(anyhow!("relationship alteration is empty")); + } value.version = version; } RelationshipMutationOperation::Retire => { @@ -2258,6 +2318,7 @@ pub fn validate_component_world(state: &ComponentWorldState) -> Result<()> { active_subject(state, resource)?; require_kind(resource, SubjectKind::Resource)?; if &value.resource != resource + || value.label.trim().is_empty() || value.quantity <= 0 || value.integrity < 0 || value.integrity > 100 @@ -2796,27 +2857,36 @@ fn string_schema_fields( Relocate => vec![(RouteId, "route_id", Required)], ResourceCreate => vec![ (ResourceKind, "resource_kind", Required), + (ResourceLabel, "resource_label", Required), (RecipeId, "recipe_id", Optional), ], ResourceTransform | ResourceSplit | ResourceCombine => vec![ (ResourceKind, "resource_kind", Optional), + (ResourceLabel, "resource_label", Optional), (RecipeId, "recipe_id", Required), ], ResourceConsume | ResourceDamage | ResourceRepair => vec![ (ResourceKind, "resource_kind", Optional), + (ResourceLabel, "resource_label", Optional), (RecipeId, "recipe_id", Optional), ], - CapabilityGrant | CapabilityAlter | CapabilitySuspend | CapabilityRetire => { - vec![(CapabilityId, "capability_id", Required)] - } - ConditionApply | ConditionAlter | ConditionClear => { - vec![(ConditionId, "condition_id", Required)] - } + CapabilityGrant | CapabilityAlter | CapabilitySuspend | CapabilityRetire => vec![ + (CapabilityId, "capability_id", Required), + (Description, "description", Optional), + ], + ConditionApply | ConditionAlter | ConditionClear => vec![ + (ConditionId, "condition_id", Required), + (Description, "description", Optional), + ], CommitmentCreate | CommitmentAlter | CommitmentFulfill | CommitmentDefault - | CommitmentRetire => vec![(CommitmentId, "commitment_id", Required)], - RelationshipCreate | RelationshipAlter | RelationshipRetire => { - vec![(RelationshipId, "relationship_id", Required)] - } + | CommitmentRetire => vec![ + (CommitmentId, "commitment_id", Required), + (Description, "description", Optional), + ], + RelationshipCreate | RelationshipAlter | RelationshipRetire => vec![ + (RelationshipId, "relationship_id", Required), + (Description, "description", Optional), + ], PressureCreate => vec![(PressureLabel, "label", Required)], PressureAdvance | PressureReduce | PressureResolve | PressureRetire => { vec![(PressureLabel, "label", Optional)] @@ -2824,6 +2894,7 @@ fn string_schema_fields( MemoryRecord | MemoryRevise | MemoryRetire => vec![ (MemoryId, "memory_id", Required), (EventId, "event_id", Optional), + (Summary, "summary", Optional), ], PostureChange => vec![(Posture, "posture", Required)], IdentityAdopt => vec![ @@ -2838,10 +2909,10 @@ fn string_schema_fields( vec![(TopologyEdgeId, "edge_id", Required)] } AdmitEntity => vec![(AdmissionReceiptId, "admission_receipt_id", Required)], + RetireEntity => vec![(RetirementReason, "reason", Required)], TransferCustody | KnowledgeAcquire | KnowledgeCommunicate | KnowledgeConceal | KnowledgeCorrect | KnowledgeInvalidate | PopulationJoin | PopulationLeave - | PopulationTransfer | PopulationSplit | PopulationMerge | RetireEntity - | AdvanceWorldTime => vec![], + | PopulationTransfer | PopulationSplit | PopulationMerge | AdvanceWorldTime => vec![], } } @@ -2867,6 +2938,9 @@ fn integer_schema_fields( ConditionApply | ConditionAlter | ConditionClear => { vec![(Severity, "severity", Optional)] } + RelationshipCreate | RelationshipAlter => { + vec![(RelationshipStrengthDelta, "strength_delta", Optional)] + } PressureAdvance | PressureReduce => vec![(PressureAmount, "amount", Required)], PressureCreate | PressureResolve | PressureRetire => { vec![(PressureAmount, "amount", Optional)] @@ -2878,12 +2952,12 @@ fn integer_schema_fields( AdvanceWorldTime => vec![(WorldMinutes, "minutes", Required)], Relocate | TransferCustody | CapabilityGrant | CapabilityAlter | CapabilitySuspend | CapabilityRetire | CommitmentCreate | CommitmentAlter | CommitmentFulfill - | CommitmentDefault | CommitmentRetire | RelationshipCreate | RelationshipAlter - | RelationshipRetire | KnowledgeAcquire | KnowledgeCommunicate | KnowledgeConceal - | KnowledgeCorrect | KnowledgeInvalidate | MemoryRecord | MemoryRevise | MemoryRetire - | PostureChange | PopulationJoin | PopulationLeave | PopulationTransfer - | PopulationSplit | PopulationMerge | IdentityAdopt | IdentityDisclose - | IdentityRestrict | IdentityRetire | AdmitEntity | RetireEntity => vec![], + | CommitmentDefault | CommitmentRetire | RelationshipRetire | KnowledgeAcquire + | KnowledgeCommunicate | KnowledgeConceal | KnowledgeCorrect | KnowledgeInvalidate + | MemoryRecord | MemoryRevise | MemoryRetire | PostureChange | PopulationJoin + | PopulationLeave | PopulationTransfer | PopulationSplit | PopulationMerge + | IdentityAdopt | IdentityDisclose | IdentityRestrict | IdentityRetire | AdmitEntity + | RetireEntity => vec![], } } @@ -3253,28 +3327,60 @@ impl WorldMutation { } MutateResource { resource_kind, + resource_label, recipe_id, .. } => { if let Some(value) = resource_kind { values.push((MutationStringRole::ResourceKind, value.clone())); } + if let Some(value) = resource_label { + values.push((MutationStringRole::ResourceLabel, value.clone())); + } if let Some(value) = recipe_id { values.push((MutationStringRole::RecipeId, value.clone())); } } - ChangeCapability { capability_id, .. } => { - values.push((MutationStringRole::CapabilityId, capability_id.clone())) + ChangeCapability { + capability_id, + description, + .. + } => { + values.push((MutationStringRole::CapabilityId, capability_id.clone())); + if let Some(value) = description { + values.push((MutationStringRole::Description, value.clone())); + } } - ChangeCondition { condition_id, .. } => { - values.push((MutationStringRole::ConditionId, condition_id.clone())) + ChangeCondition { + condition_id, + description, + .. + } => { + values.push((MutationStringRole::ConditionId, condition_id.clone())); + if let Some(value) = description { + values.push((MutationStringRole::Description, value.clone())); + } } - ChangeCommitment { commitment_id, .. } => { - values.push((MutationStringRole::CommitmentId, commitment_id.clone())) + ChangeCommitment { + commitment_id, + description, + .. + } => { + values.push((MutationStringRole::CommitmentId, commitment_id.clone())); + if let Some(value) = description { + values.push((MutationStringRole::Description, value.clone())); + } } ChangeRelationship { - relationship_id, .. - } => values.push((MutationStringRole::RelationshipId, relationship_id.clone())), + relationship_id, + description, + .. + } => { + values.push((MutationStringRole::RelationshipId, relationship_id.clone())); + if let Some(value) = description { + values.push((MutationStringRole::Description, value.clone())); + } + } ChangePressure { label, .. } => { if let Some(value) = label { values.push((MutationStringRole::PressureLabel, value.clone())); @@ -3283,12 +3389,16 @@ impl WorldMutation { ChangeMemory { memory_id, event_id, + summary, .. } => { values.push((MutationStringRole::MemoryId, memory_id.clone())); if let Some(value) = event_id { values.push((MutationStringRole::EventId, value.clone())); } + if let Some(value) = summary { + values.push((MutationStringRole::Summary, value.clone())); + } } ChangePosture { posture, .. } => { values.push((MutationStringRole::Posture, posture.clone())) @@ -3313,11 +3423,13 @@ impl WorldMutation { MutationStringRole::AdmissionReceiptId, admission_receipt_id.clone(), )), + RetireEntity { reason, .. } => { + values.push((MutationStringRole::RetirementReason, reason.clone())) + } TransferCustody { .. } | ChangeKnowledge { .. } | ChangePopulationMembership { .. } | ChangePopulationLineage { .. } - | RetireEntity { .. } | AdvanceWorldTime { .. } => {} } values @@ -3344,6 +3456,11 @@ impl WorldMutation { values.push((MutationIntegerRole::Severity, *value)); } } + ChangeRelationship { strength_delta, .. } => { + if let Some(value) = strength_delta { + values.push((MutationIntegerRole::RelationshipStrengthDelta, *value)); + } + } ChangePressure { amount, .. } => { if let Some(value) = amount { values.push((MutationIntegerRole::PressureAmount, *value)); @@ -3361,7 +3478,6 @@ impl WorldMutation { | TransferCustody { .. } | ChangeCapability { .. } | ChangeCommitment { .. } - | ChangeRelationship { .. } | ChangeKnowledge { .. } | ChangeMemory { .. } | ChangePosture { .. } @@ -3392,6 +3508,7 @@ impl WorldMutation { operation, custodian, related_resources, + resource_label, quantity, integrity, .. @@ -3418,14 +3535,41 @@ impl WorldMutation { ResourceMutationOperation::Create if custodian.is_none() => { return Err(anyhow!("resource creation requires an exact custodian")); } + ResourceMutationOperation::Create + if resource_label.as_deref().is_none_or(str::is_empty) => + { + return Err(anyhow!("resource creation requires an exact label")); + } + _ if custodian.is_none() => { + return Err(anyhow!( + "resource mutation requires exact custody authority" + )); + } _ => {} }, ChangeCapability { capability_id, .. } => nonempty("capability id", capability_id)?, ChangeCondition { condition_id, .. } => nonempty("condition id", condition_id)?, ChangeCommitment { commitment_id, .. } => nonempty("commitment id", commitment_id)?, ChangeRelationship { - relationship_id, .. - } => nonempty("relationship id", relationship_id)?, + operation, + relationship_id, + description, + strength_delta, + .. + } => { + nonempty("relationship id", relationship_id)?; + if matches!(operation, RelationshipMutationOperation::Create) + && description.as_deref().is_none_or(str::is_empty) + { + return Err(anyhow!("relationship creation requires a description")); + } + if matches!(operation, RelationshipMutationOperation::Alter) + && description.is_none() + && strength_delta.is_none() + { + return Err(anyhow!("relationship alteration is empty")); + } + } ChangePressure { operation, amount, .. } if matches!( @@ -3566,6 +3710,60 @@ impl WorldMutation { } } +/// Compile a single-use permit that authorizes exactly one already-admitted +/// semantic mutation. This is the compatibility cut for legacy outcome +/// records: they may be lowered into the algebra, but they do not retain an +/// independent write policy. +pub fn exact_mutation_permit( + id: impl Into, + mutation: &WorldMutation, +) -> Result { + mutation.validate_local_shape()?; + let subject_bindings = mutation + .subject_roles() + .into_iter() + .map(|(role, allowed_subjects)| MutationSubjectBinding { + role, + allowed_subjects, + }) + .collect(); + let mut string_constraints = BTreeMap::new(); + for (role, value) in mutation.string_roles() { + let length = u16::try_from(value.chars().count()) + .map_err(|_| anyhow!("world mutation string exceeds permit capacity"))?; + let constraint = string_constraints + .entry(role) + .or_insert_with(|| StringConstraint { + allowed_values: BTreeSet::new(), + minimum_length: 1, + maximum_length: length.max(1), + }); + constraint.maximum_length = constraint.maximum_length.max(length); + constraint.allowed_values.insert(value); + } + let integer_bounds = mutation + .integer_roles() + .into_iter() + .map(|(role, value)| { + ( + role, + IntegerBounds { + minimum: value, + maximum: value, + }, + ) + }) + .collect(); + Ok(MutationPermit { + id: id.into(), + operation: mutation.operation(), + subject_bindings, + string_constraints, + integer_bounds, + maximum_uses: 1, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -3682,6 +3880,27 @@ mod tests { ); } + #[test] + fn exact_permit_binds_semantic_description_not_only_subject_and_operation() { + let mutation = WorldMutation::ChangeRelationship { + source: subject(SubjectKind::Actor, "actor:ash"), + target: subject(SubjectKind::Actor, "actor:sable"), + operation: RelationshipMutationOperation::Create, + relationship_id: "relationship:ash:sable".into(), + description: Some("Ash trusts Sable with the route.".into()), + strength_delta: None, + }; + let permit = exact_mutation_permit("permit:relationship", &mutation).unwrap(); + validate_permitted_mutation(&permit, &mutation).unwrap(); + + let mut rewritten = mutation; + let WorldMutation::ChangeRelationship { description, .. } = &mut rewritten else { + unreachable!(); + }; + *description = Some("Ash owes Sable absolute obedience.".into()); + assert!(validate_permitted_mutation(&permit, &rewritten).is_err()); + } + #[test] fn stale_or_partially_invalid_batch_has_no_structural_admission() { let envelope = envelope(); @@ -3835,6 +4054,7 @@ mod tests { schema: "ghostlight.resource_component.v1".into(), resource: medicine, resource_kind: "medicine".into(), + label: "medicine lot".into(), quantity: 10, integrity: 100, qualities: BTreeSet::new(), @@ -3873,7 +4093,17 @@ mod tests { )]), population_lineages: BTreeMap::new(), identities: BTreeMap::new(), - topology: BTreeMap::new(), + topology: BTreeMap::from([( + "route:camp-trail".into(), + TopologyComponentState { + id: "route:camp-trail".into(), + from_place: camp, + to_place: subject(SubjectKind::Place, "place:trail"), + travel_minutes: 30, + open: true, + version: 4, + }, + )]), } } @@ -4056,6 +4286,7 @@ mod tests { MutationSubjectRole::RelatedResource, BTreeSet::from([child.clone()]), ), + (MutationSubjectRole::Owner, BTreeSet::from([sable.clone()])), ], vec![( MutationStringRole::RecipeId, @@ -4100,9 +4331,10 @@ mod tests { WorldMutation::MutateResource { resource: source.clone(), operation: ResourceMutationOperation::Split, - custodian: None, + custodian: Some(sable.clone()), related_resources: vec![child.clone()], resource_kind: None, + resource_label: None, recipe_id: Some("recipe:measure-dose".into()), quantity: Some(4), integrity: None,