Unify world transitions through typed mutation algebra

This commit is contained in:
Meta Vens 2026-08-23 14:28:02 +02:00
commit d730d1fd70
5 changed files with 3044 additions and 493 deletions

View file

@ -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<Event>,
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<Vec<crate::domain::Event>, KernelError> {
) -> Result<AppliedStrategicTickPlan, KernelError> {
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<String,
.ok_or_else(|| KernelError::Invalid("strategic activity target vanished".into()))
}
fn rebase_member_migration(
campaign: &mut Campaign,
action: &crate::domain::StrategicMemberMigration,
) -> 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<crate::domain::Event> {
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<String>,
event_ids: Vec<String>,
resolution_wave: Option<ResolutionWaveCommit>,
mutation: Option<(
crate::legacy_transition::LoweredLegacyTransition,
crate::transition::WorldMutationReceipt,
)>,
) -> Result<CommandResult, KernelError> {
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<RollReceipt>,
transition: crate::legacy_transition::LoweredLegacyTransition,
mutation_receipt: crate::transition::WorldMutationReceipt,
) -> Result<CommandResult, KernelError> {
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<CommandResult, KernelError> {
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]

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -357,6 +357,72 @@ impl CampaignStore {
Ok(next_row)
}
pub fn append_world_transition_with_mutation<T: Serialize>(
&self,
expected: &CultCacheEnvelope,
next_schema: &str,
next: &T,
receipt_key: &str,
receipt: &WorldCommitReceipt,
authority: &MutationAuthorityEnvelope,
batch: &WorldMutationBatch,
mutation_receipt: &WorldMutationReceipt,
) -> Result<CultCacheEnvelope> {
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<CultCacheEnvelope> {
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<CultCacheEnvelope> {
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<CultCacheEnvelope> {
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<CultCacheEnvelope>,
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],

View file

@ -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<StrategicOutcomeSourceBinding>),
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<SubjectRef>,
resource_kind: Option<String>,
resource_label: Option<String>,
recipe_id: Option<String>,
quantity: Option<i64>,
integrity: Option<i64>,
@ -471,6 +485,7 @@ pub enum WorldMutation {
relationship_id: String,
#[schemars(length(max = 1000))]
description: Option<String>,
strength_delta: Option<i64>,
},
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<i64>,
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<i64>,
integrity: Option<i64>,
) -> 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<i64>,
) -> 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<String>,
mutation: &WorldMutation,
) -> Result<MutationPermit> {
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,