Unify world transitions through typed mutation algebra
This commit is contained in:
parent
8568b37f16
commit
d730d1fd70
5 changed files with 3044 additions and 493 deletions
|
|
@ -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]
|
||||
|
|
|
|||
2308
crates/ghostlight-dungeon/src/legacy_transition.rs
Normal file
2308
crates/ghostlight-dungeon/src/legacy_transition.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue