From bee96763e645b2183b4ebda722a04f05da7cb468 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 15:33:03 +0200 Subject: [PATCH] Route region expansion through mutation algebra --- crates/ghostlight-dungeon/src/compiler.rs | 166 ++++++- crates/ghostlight-dungeon/src/domain.rs | 8 +- crates/ghostlight-dungeon/src/kernel.rs | 84 +++- .../src/legacy_transition.rs | 259 +++++++++- crates/ghostlight-dungeon/src/main.rs | 2 + crates/ghostlight-dungeon/src/transition.rs | 467 +++++++++++++++++- 6 files changed, 941 insertions(+), 45 deletions(-) diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index f406a42..152b57b 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -274,6 +274,7 @@ struct CompiledAgencyRelation { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] struct CompiledExpansionSeed { + origin_routes: BTreeMap, locations: Vec, facts: Vec, gaps: Vec, @@ -1117,7 +1118,7 @@ impl WorldCompiler { .await?; let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision); let base_prompt = format!( - "Compile only the requested bounded destination region. Every new location id must be new. At least one new location must route back to origin id {} with a positive travel time. Do not rewrite existing geography. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}", + "Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes owned by origin id {} into the new region, and give every such destination a reciprocal route back to the origin with the same positive travel time. Route IDs are globally unique. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}", origin_location_id, serde_json::to_string(&campaign.locations)?, destination_request, @@ -1141,6 +1142,7 @@ impl WorldCompiler { let seed: CompiledExpansionSeed = serde_json::from_value(output.0)?; let expansion = crate::domain::RegionExpansion { origin_location_id: origin_location_id.into(), + origin_routes: seed.origin_routes.clone(), locations: seed.locations.clone(), facts: seed.facts.clone(), }; @@ -2425,7 +2427,10 @@ pub fn validate_region_expansion( return Err(anyhow!("destination expansion origin is unknown")); } let new_ids: BTreeSet<_> = expansion.locations.iter().map(|x| x.id.as_str()).collect(); - if expansion.locations.is_empty() || new_ids.len() != expansion.locations.len() { + if expansion.locations.is_empty() + || new_ids.len() != expansion.locations.len() + || new_ids.iter().any(|id| id.trim().is_empty()) + { return Err(anyhow!("destination expansion has no unique locations")); } if new_ids @@ -2437,10 +2442,76 @@ pub fn validate_region_expansion( )); } let known = |id: &str| campaign.locations.contains_key(id) || new_ids.contains(id); + let new_locations = expansion + .locations + .iter() + .map(|location| (location.id.as_str(), location)) + .collect::>(); + let existing_route_ids = campaign + .locations + .values() + .flat_map(|location| location.routes.keys().map(String::as_str)) + .collect::>(); + let mut new_route_ids = BTreeSet::new(); + if expansion.origin_routes.is_empty() { + return Err(anyhow!( + "destination expansion has no explicit route from the origin" + )); + } + for (route_id, route) in &expansion.origin_routes { + if route_id.trim().is_empty() + || existing_route_ids.contains(route_id.as_str()) + || !new_route_ids.insert(route_id.as_str()) + || route.travel_minutes == 0 + || route.distance.trim().is_empty() + || !new_ids.contains(route.destination_id.as_str()) + { + return Err(anyhow!("destination expansion has an invalid origin route")); + } + let destination = new_locations + .get(route.destination_id.as_str()) + .expect("origin route destination was admitted above"); + if !destination.routes.values().any(|reverse| { + reverse.destination_id == expansion.origin_location_id + && reverse.travel_minutes == route.travel_minutes + }) { + return Err(anyhow!( + "destination expansion origin route lacks an exact reciprocal return route" + )); + } + } let mut attached = false; for location in &expansion.locations { - for route in location.routes.values() { - if route.travel_minutes == 0 || !known(&route.destination_id) { + if location.name.trim().is_empty() + || location + .persistent_features + .iter() + .any(|feature| feature.trim().is_empty()) + || location + .persistent_features + .iter() + .collect::>() + .len() + != location.persistent_features.len() + { + return Err(anyhow!( + "destination expansion has a malformed place profile" + )); + } + if let Some(container_id) = &location.container_id + && (!known(container_id) || container_id == &location.id) + { + return Err(anyhow!("destination expansion has an invalid container")); + } + for (route_id, route) in &location.routes { + if route_id.trim().is_empty() + || existing_route_ids.contains(route_id.as_str()) + || !new_route_ids.insert(route_id.as_str()) + || route.travel_minutes == 0 + || route.distance.trim().is_empty() + || !known(&route.destination_id) + || route.destination_id == location.id + { return Err(anyhow!("destination expansion has a dangling route")); } if route.destination_id == expansion.origin_location_id { @@ -2451,6 +2522,26 @@ pub fn validate_region_expansion( if !attached { return Err(anyhow!("destination expansion is not attached to origin")); } + for location in &expansion.locations { + let mut seen = BTreeSet::from([location.id.as_str()]); + let mut cursor = location.container_id.as_deref(); + while let Some(container_id) = cursor { + if !seen.insert(container_id) { + return Err(anyhow!( + "destination expansion containment contains a cycle" + )); + } + cursor = new_locations + .get(container_id) + .and_then(|container| container.container_id.as_deref()) + .or_else(|| { + campaign + .locations + .get(container_id) + .and_then(|container| container.container_id.as_deref()) + }); + } + } let existing_fact_ids = campaign.facts.keys().collect::>(); let mut new_fact_ids = BTreeSet::new(); let mut fact_statements = campaign @@ -2464,6 +2555,10 @@ pub fn validate_region_expansion( || !new_fact_ids.insert(fact.id.clone()) || fact.statement.trim().is_empty() || !fact_statements.insert(fact.statement.clone()) + || fact + .evidence_receipt_ids + .iter() + .any(|receipt_id| receipt_id.trim().is_empty()) { return Err(anyhow!( "destination expansion facts must have new IDs and non-empty unique statements" @@ -4925,4 +5020,67 @@ mod tests { assert_eq!(world_receipts[0].validation_result, "semantic_invalid"); assert_eq!(world_receipts[1].validation_result, "valid"); } + + fn valid_region_expansion() -> crate::domain::RegionExpansion { + crate::domain::RegionExpansion { + origin_location_id: "center".into(), + origin_routes: BTreeMap::from([( + "route:center-annex".into(), + crate::domain::Route { + destination_id: "annex".into(), + distance: "near".into(), + travel_minutes: 10, + }, + )]), + locations: vec![Location { + id: "annex".into(), + name: "Annex".into(), + container_id: None, + routes: BTreeMap::from([( + "route:annex-center".into(), + crate::domain::Route { + destination_id: "center".into(), + distance: "near".into(), + travel_minutes: 10, + }, + )]), + persistent_features: vec!["sealed gate".into()], + }], + facts: vec![], + } + } + + #[test] + fn region_expansion_requires_an_approved_round_trip() { + let campaign = crate::resolution::tests::campaign(0, 1); + let mut expansion = valid_region_expansion(); + validate_region_expansion(&campaign, &expansion).unwrap(); + expansion.origin_routes.clear(); + assert!( + validate_region_expansion(&campaign, &expansion) + .unwrap_err() + .to_string() + .contains("explicit route from the origin") + ); + } + + #[test] + fn region_expansion_rejects_containment_cycles() { + let campaign = crate::resolution::tests::campaign(0, 1); + let mut expansion = valid_region_expansion(); + expansion.locations[0].container_id = Some("service-ring".into()); + expansion.locations.push(Location { + id: "service-ring".into(), + name: "Service Ring".into(), + container_id: Some("annex".into()), + routes: BTreeMap::new(), + persistent_features: vec!["fixed conduits".into()], + }); + assert!( + validate_region_expansion(&campaign, &expansion) + .unwrap_err() + .to_string() + .contains("containment contains a cycle") + ); + } } diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 91ba757..c7c95e1 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -830,7 +830,7 @@ pub struct CanonCandidate { pub status: String, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FactScope { CanonBaseline, @@ -1027,6 +1027,12 @@ fn default_true() -> bool { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct RegionExpansion { pub origin_location_id: String, + /// Exact outbound routes added to the already-materialized origin. Keeping + /// these separate from `locations` makes the existing-place mutation + /// explicit in the approval preview instead of deriving a hidden reverse + /// route after commit. + #[serde(default)] + pub origin_routes: BTreeMap, pub locations: Vec, pub facts: Vec, } diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 04e4472..8a49ec2 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1088,14 +1088,39 @@ fn execute( model_stage_receipts, } => { require_revision(&campaign, expected_revision)?; + let supplied_evidence = evidence_receipts + .iter() + .map(|receipt| receipt.id.as_str()) + .collect::>(); + if expansion.facts.iter().any(|fact| { + fact.evidence_receipt_ids + .iter() + .any(|id| !supplied_evidence.contains(id.as_str())) + }) || canon_candidates.iter().any(|candidate| { + candidate + .evidence_receipt_ids + .iter() + .any(|id| !supplied_evidence.contains(id.as_str())) + }) { + return Err(KernelError::Invalid( + "region expansion evidence receipts were not supplied".into(), + )); + } crate::compiler::validate_region_expansion(&campaign, &expansion) .map_err(|error| KernelError::Invalid(error.to_string()))?; - for location in expansion.locations { - campaign.locations.insert(location.id.clone(), location); - } - for fact in expansion.facts { - campaign.facts.insert(fact.id.clone(), fact); - } + let transition = crate::legacy_transition::lower_region_expansion( + &campaign, + &expansion, + Utc::now() + Duration::minutes(5), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mutation_receipt = crate::legacy_transition::apply_lowered_region_expansion( + &mut campaign, + &expansion, + &transition, + Utc::now(), + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; for candidate in &canon_candidates { campaign .canon_candidates @@ -1109,7 +1134,7 @@ fn execute( evidence_receipts, canon_candidates, model_stage_receipts, - None, + Some((transition, mutation_receipt)), ) } WorldCommand::MaterializeGestaltMember { @@ -3314,6 +3339,8 @@ mod tests { memberships: BTreeMap::new(), population_lineages: BTreeMap::new(), identities: BTreeMap::new(), + place_profiles: BTreeMap::new(), + propositions: BTreeMap::new(), topology: BTreeMap::new(), }; let initial_world_time = state.world_time; @@ -3334,6 +3361,7 @@ mod tests { maximum: 30, }, )]), + exact_mutation: None, maximum_uses: 1, }; let mut authority = MutationAuthorityEnvelope { @@ -5219,8 +5247,22 @@ mod tests { expected_revision: 0, expansion: RegionExpansion { origin_location_id: "room".into(), + origin_routes: BTreeMap::from([( + "to-annex".into(), + Route { + destination_id: "annex".into(), + distance: "near".into(), + travel_minutes: 10, + }, + )]), locations: vec![location], - facts: vec![], + facts: vec![WorldFact { + id: "annex-gate".into(), + statement: "The annex gate is maintained from the inner booth.".into(), + scope: FactScope::BranchLocal, + evidence_receipt_ids: vec![evidence.id.clone()], + discoverable_at_location_ids: BTreeSet::from(["annex".into()]), + }], }, evidence_receipts: vec![evidence], canon_candidates: vec![candidate], @@ -5232,9 +5274,35 @@ mod tests { panic!("expected commit") }; assert!(campaign.locations.contains_key("annex")); + assert_eq!( + campaign.locations["room"].routes["to-annex"].destination_id, + "annex" + ); assert_eq!(campaign.revision, 1); + assert_eq!( + campaign.facts["annex-gate"].discoverable_at_location_ids, + BTreeSet::from(["annex".into()]) + ); assert_eq!(store.keys("vault_evidence_receipt.v1").unwrap().len(), 1); assert_eq!(store.keys("canon_candidate.v1").unwrap().len(), 1); + assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1); + assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); + let batches = store + .load_all::("world_mutation_batch.v1") + .unwrap(); + assert_eq!(batches[0].1.mutations.len(), 4); + assert_eq!( + batches[0] + .1 + .mutations + .iter() + .filter(|mutation| matches!( + &mutation.mutation, + crate::transition::WorldMutation::AdmitEntity { .. } + )) + .count(), + 2 + ); let (_, manifest): (_, VaultManifest) = store .load("vault_manifest.v1", &seed.id.to_string()) .unwrap() diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index e0006ce..05dfdd3 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -170,6 +170,7 @@ pub fn lower_fission( WorldComponentKind::PopulationLineage, ]), initial_place: Some(place_subject(&child.home_location_id)), + initial_profile: None, admission_receipt_id: source_receipt_id.clone(), }); mutations.push(WorldMutation::ChangeIdentity { @@ -246,6 +247,196 @@ pub fn apply_lowered_fission( Ok(application.receipt) } +pub fn lower_region_expansion( + campaign: &Campaign, + expansion: &crate::domain::RegionExpansion, + expires_at: DateTime, +) -> Result { + crate::compiler::validate_region_expansion(campaign, expansion)?; + let expansion_digest = digest_serializable(expansion)?; + let source_receipt_id = format!( + "region-expansion:{}", + expansion_digest.trim_start_matches("sha256:") + ); + let mut mutations = Vec::new(); + for location in &expansion.locations { + mutations.push(WorldMutation::AdmitEntity { + subject: place_subject(&location.id), + initial_components: BTreeSet::from([ + WorldComponentKind::PlaceProfile, + WorldComponentKind::Topology, + ]), + initial_place: None, + initial_profile: Some(AdmittedEntityProfile::Place { + name: location.name.clone(), + container: location.container_id.as_deref().map(place_subject), + persistent_features: location.persistent_features.iter().cloned().collect(), + }), + admission_receipt_id: source_receipt_id.clone(), + }); + } + for fact in &expansion.facts { + mutations.push(WorldMutation::AdmitEntity { + subject: proposition_subject(&fact.id), + initial_components: BTreeSet::from([ + WorldComponentKind::Knowledge, + WorldComponentKind::PropositionContent, + ]), + initial_place: None, + initial_profile: Some(AdmittedEntityProfile::Proposition { + statement: fact.statement.clone(), + scope: fact.scope.clone(), + evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(), + discoverable_at_places: fact + .discoverable_at_location_ids + .iter() + .map(|id| place_subject(id)) + .collect(), + }), + admission_receipt_id: source_receipt_id.clone(), + }); + } + let origin = place_subject(&expansion.origin_location_id); + for (route_id, route) in &expansion.origin_routes { + mutations.push(WorldMutation::ChangeTopology { + operation: TopologyMutationOperation::Add, + edge_id: route_id.clone(), + from_place: origin.clone(), + to_place: place_subject(&route.destination_id), + distance: Some(route.distance.clone()), + travel_minutes: Some(i64::from(route.travel_minutes)), + }); + } + for location in &expansion.locations { + for (route_id, route) in &location.routes { + mutations.push(WorldMutation::ChangeTopology { + operation: TopologyMutationOperation::Add, + edge_id: route_id.clone(), + from_place: place_subject(&location.id), + to_place: place_subject(&route.destination_id), + distance: Some(route.distance.clone()), + travel_minutes: Some(i64::from(route.travel_minutes)), + }); + } + } + lower_exact_mutations( + campaign, + mutations, + MutationProcedure::CompilerAdmission, + None, + MutationOutcomeBinding::Deterministic, + None, + "Admit only the approved place profiles and proposition contents, then add the exact approved route edges without rewriting existing geography.", + &source_receipt_id, + Some(digest_serializable(&( + "compile_destination", + &expansion.origin_location_id, + ))?), + Some(expansion_digest), + expires_at, + ) +} + +pub fn apply_lowered_region_expansion( + campaign: &mut Campaign, + expansion: &crate::domain::RegionExpansion, + transition: &LoweredLegacyTransition, + now: DateTime, +) -> Result { + crate::compiler::validate_region_expansion(campaign, expansion)?; + let snapshot = component_snapshot(campaign)?; + let application = + apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?; + project_accepted_region_expansion(campaign, expansion, &application.state)?; + Ok(application.receipt) +} + +fn project_accepted_region_expansion( + campaign: &mut Campaign, + expansion: &crate::domain::RegionExpansion, + next: &ComponentWorldState, +) -> Result<()> { + for requested in &expansion.locations { + let subject = place_subject(&requested.id); + let profile = next + .place_profiles + .get(&subject) + .ok_or_else(|| anyhow!("accepted expansion lost a place profile"))?; + let routes = next + .topology + .values() + .filter(|edge| edge.from_place == subject && edge.open) + .map(|edge| { + Ok(( + edge.id.clone(), + crate::domain::Route { + destination_id: edge.to_place.id.clone(), + distance: edge.distance.clone(), + travel_minutes: u32::try_from(edge.travel_minutes).map_err(|_| { + anyhow!("accepted expansion travel time exceeds aggregate storage") + })?, + }, + )) + }) + .collect::>>()?; + campaign.locations.insert( + subject.id.clone(), + crate::domain::Location { + id: subject.id, + name: profile.name.clone(), + container_id: profile + .container + .as_ref() + .map(|container| container.id.clone()), + routes, + persistent_features: profile.persistent_features.iter().cloned().collect(), + }, + ); + } + let origin = campaign + .locations + .get_mut(&expansion.origin_location_id) + .ok_or_else(|| anyhow!("accepted expansion origin vanished"))?; + for route_id in expansion.origin_routes.keys() { + let edge = next + .topology + .get(route_id) + .filter(|edge| edge.from_place.id == expansion.origin_location_id && edge.open) + .ok_or_else(|| anyhow!("accepted expansion lost an origin route"))?; + origin.routes.insert( + route_id.clone(), + crate::domain::Route { + destination_id: edge.to_place.id.clone(), + distance: edge.distance.clone(), + travel_minutes: u32::try_from(edge.travel_minutes) + .map_err(|_| anyhow!("accepted origin route exceeds aggregate storage"))?, + }, + ); + } + for requested in &expansion.facts { + let subject = proposition_subject(&requested.id); + let value = next + .propositions + .get(&subject) + .ok_or_else(|| anyhow!("accepted expansion lost proposition content"))?; + campaign.facts.insert( + subject.id.clone(), + crate::domain::WorldFact { + id: subject.id, + statement: value.statement.clone(), + scope: value.scope.clone(), + evidence_receipt_ids: value.evidence_receipt_ids.iter().cloned().collect(), + discoverable_at_location_ids: value + .discoverable_at_places + .iter() + .map(|place| place.id.clone()) + .collect(), + }, + ); + } + Ok(()) +} + fn project_accepted_fission( campaign: &mut Campaign, preview: &GestaltFissionPreview, @@ -1055,6 +1246,8 @@ fn component_snapshot(campaign: &Campaign) -> Result { memberships: BTreeMap::new(), population_lineages: BTreeMap::new(), identities: BTreeMap::new(), + place_profiles: BTreeMap::new(), + propositions: BTreeMap::new(), topology: BTreeMap::new(), }; admit( @@ -1067,9 +1260,23 @@ fn component_snapshot(campaign: &Campaign) -> Result { admit( &mut state, place_subject(&location.id), - BTreeSet::from([WorldComponentKind::Topology]), + BTreeSet::from([ + WorldComponentKind::PlaceProfile, + WorldComponentKind::Topology, + ]), version, ); + let place = place_subject(&location.id); + state.place_profiles.insert( + place.clone(), + PlaceComponentState { + place, + name: location.name.clone(), + container: location.container_id.as_deref().map(place_subject), + persistent_features: location.persistent_features.iter().cloned().collect(), + version, + }, + ); } for actor in campaign.actors.values() { let subject = actor_subject(&actor.id); @@ -1439,12 +1646,31 @@ fn component_snapshot(campaign: &Campaign) -> Result { ); } for fact in campaign.facts.values() { + let proposition = proposition_subject(&fact.id); admit( &mut state, - proposition_subject(&fact.id), - BTreeSet::from([WorldComponentKind::Knowledge]), + proposition.clone(), + BTreeSet::from([ + WorldComponentKind::Knowledge, + WorldComponentKind::PropositionContent, + ]), version, ); + state.propositions.insert( + proposition.clone(), + PropositionComponentState { + proposition, + statement: fact.statement.clone(), + scope: fact.scope.clone(), + evidence_receipt_ids: fact.evidence_receipt_ids.iter().cloned().collect(), + discoverable_at_places: fact + .discoverable_at_location_ids + .iter() + .map(|id| place_subject(id)) + .collect(), + version, + }, + ); } for clock in campaign.clocks.values() { let pressure = pressure_subject(&clock.id); @@ -1475,6 +1701,7 @@ fn component_snapshot(campaign: &Campaign) -> Result { id: route_id.clone(), from_place: place_subject(&location.id), to_place: place_subject(&route.destination_id), + distance: route.distance.clone(), travel_minutes: i64::from(route.travel_minutes), open: true, version, @@ -1492,15 +1719,7 @@ fn component_snapshot(campaign: &Campaign) -> Result { { proposition_subject(&fact.id) } else { - let proposition = - proposition_subject(&format!("legacy-knowledge:{}", short_digest(statement))); - admit( - &mut state, - proposition.clone(), - BTreeSet::from([WorldComponentKind::Knowledge]), - version, - ); - proposition + ensure_proposition(&mut state, campaign, statement, version) }; state.knowledge.insert( KnowledgeKey { @@ -2211,9 +2430,23 @@ fn ensure_proposition( admit( state, proposition.clone(), - BTreeSet::from([WorldComponentKind::Knowledge]), + BTreeSet::from([ + WorldComponentKind::Knowledge, + WorldComponentKind::PropositionContent, + ]), version, ); + state + .propositions + .entry(proposition.clone()) + .or_insert_with(|| PropositionComponentState { + proposition: proposition.clone(), + statement: statement.into(), + scope: crate::domain::FactScope::ProvisionalLocal, + evidence_receipt_ids: BTreeSet::new(), + discoverable_at_places: BTreeSet::new(), + version, + }); proposition } diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 8e44850..d426efd 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -4853,7 +4853,9 @@ fn world_compile_preview_projection(preview: &WorldCompilePreview) -> serde_json fn region_expansion_preview_projection(preview: &RegionExpansionPreview) -> serde_json::Value { serde_json::json!({ "origin_location_id":preview.expansion.origin_location_id, + "origin_routes":preview.expansion.origin_routes, "locations":preview.expansion.locations, + "facts":preview.expansion.facts, "gaps":preview.gaps, "requires_approval":preview.requires_approval, }) diff --git a/crates/ghostlight-dungeon/src/transition.rs b/crates/ghostlight-dungeon/src/transition.rs index 158aa5a..457578a 100644 --- a/crates/ghostlight-dungeon/src/transition.rs +++ b/crates/ghostlight-dungeon/src/transition.rs @@ -1,4 +1,4 @@ -use crate::domain::{OutcomeBand, StrategicOutcomeBand}; +use crate::domain::{FactScope, OutcomeBand, StrategicOutcomeBand}; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use schemars::{JsonSchema, schema_for}; @@ -50,6 +50,8 @@ pub enum WorldComponentKind { Posture, PopulationMembership, PopulationLineage, + PlaceProfile, + PropositionContent, Topology, Lifecycle, WorldTime, @@ -242,6 +244,7 @@ pub enum MutationStringRole { IdentityHandleId, IdentityHandleValue, TopologyEdgeId, + Distance, AdmissionReceiptId, } @@ -290,6 +293,8 @@ pub struct MutationPermit { pub string_constraints: BTreeMap, #[serde(default)] pub integer_bounds: BTreeMap, + #[serde(default)] + pub exact_mutation: Option, #[schemars(range(min = 1))] pub maximum_uses: u16, } @@ -429,6 +434,27 @@ pub enum TopologyMutationOperation { Retire, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AdmittedEntityProfile { + Place { + #[schemars(length(min = 1, max = 240))] + name: String, + container: Option, + #[serde(default)] + persistent_features: BTreeSet, + }, + Proposition { + #[schemars(length(min = 1, max = 4000))] + statement: String, + scope: FactScope, + #[serde(default)] + evidence_receipt_ids: BTreeSet, + #[serde(default)] + discoverable_at_places: BTreeSet, + }, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WorldMutation { @@ -544,6 +570,8 @@ pub enum WorldMutation { edge_id: String, from_place: SubjectRef, to_place: SubjectRef, + #[serde(default)] + distance: Option, travel_minutes: Option, }, AdmitEntity { @@ -551,6 +579,8 @@ pub enum WorldMutation { initial_components: BTreeSet, #[serde(default)] initial_place: Option, + #[serde(default)] + initial_profile: Option, admission_receipt_id: String, }, RetireEntity { @@ -747,11 +777,35 @@ pub struct PopulationLineageState { pub version: u64, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct PlaceComponentState { + pub place: SubjectRef, + pub name: String, + pub container: Option, + #[serde(default)] + pub persistent_features: BTreeSet, + pub version: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct PropositionComponentState { + pub proposition: SubjectRef, + pub statement: String, + pub scope: FactScope, + #[serde(default)] + pub evidence_receipt_ids: BTreeSet, + #[serde(default)] + pub discoverable_at_places: BTreeSet, + pub version: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct TopologyComponentState { pub id: String, pub from_place: SubjectRef, pub to_place: SubjectRef, + #[serde(default = "default_route_distance")] + pub distance: String, pub travel_minutes: i64, pub open: bool, pub version: u64, @@ -779,9 +833,17 @@ pub struct ComponentWorldState { pub memberships: BTreeMap, pub population_lineages: BTreeMap, pub identities: BTreeMap, + #[serde(default)] + pub place_profiles: BTreeMap, + #[serde(default)] + pub propositions: BTreeMap, pub topology: BTreeMap, } +fn default_route_distance() -> String { + "unspecified".into() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MutationApplication { pub state: ComponentWorldState, @@ -1237,6 +1299,7 @@ fn apply_component_mutation( edge_id, from_place, to_place, + distance, travel_minutes, } => apply_topology_mutation( state, @@ -1244,12 +1307,14 @@ fn apply_component_mutation( edge_id, from_place, to_place, + distance.as_deref(), *travel_minutes, )?, AdmitEntity { subject, initial_components, initial_place, + initial_profile, .. } => { if subject.kind == SubjectKind::Resource { @@ -1273,6 +1338,44 @@ fn apply_component_mutation( require_kind(place, SubjectKind::Place)?; state.occupancy.insert(subject.clone(), place.clone()); } + let version = next_component_version(state); + match initial_profile { + Some(AdmittedEntityProfile::Place { + name, + container, + persistent_features, + }) => { + state.place_profiles.insert( + subject.clone(), + PlaceComponentState { + place: subject.clone(), + name: name.clone(), + container: container.clone(), + persistent_features: persistent_features.clone(), + version, + }, + ); + } + Some(AdmittedEntityProfile::Proposition { + statement, + scope, + evidence_receipt_ids, + discoverable_at_places, + }) => { + state.propositions.insert( + subject.clone(), + PropositionComponentState { + proposition: subject.clone(), + statement: statement.clone(), + scope: scope.clone(), + evidence_receipt_ids: evidence_receipt_ids.clone(), + discoverable_at_places: discoverable_at_places.clone(), + version, + }, + ); + } + None => {} + } } RetireEntity { subject, .. } => retire_entity(state, subject)?, AdvanceWorldTime { campaign, minutes } => { @@ -2315,6 +2418,7 @@ fn apply_topology_mutation( edge_id: &str, from_place: &SubjectRef, to_place: &SubjectRef, + distance: Option<&str>, travel_minutes: Option, ) -> Result<()> { active_subject(state, from_place)?; @@ -2333,12 +2437,16 @@ fn apply_topology_mutation( let travel_minutes = travel_minutes .filter(|minutes| *minutes > 0) .ok_or_else(|| anyhow!("topology edge requires positive travel time"))?; + let distance = distance + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("topology edge requires a distance description"))?; state.topology.insert( edge_id.into(), TopologyComponentState { id: edge_id.into(), from_place: from_place.clone(), to_place: to_place.clone(), + distance: distance.into(), travel_minutes, open: true, version, @@ -2356,6 +2464,10 @@ fn apply_topology_mutation( value.travel_minutes = travel_minutes .filter(|minutes| *minutes > 0) .ok_or_else(|| anyhow!("topology alteration requires positive travel time"))?; + value.distance = distance + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("topology alteration requires a distance description"))? + .into(); value.version = version; } TopologyMutationOperation::Open | TopologyMutationOperation::Close => { @@ -2390,6 +2502,7 @@ fn apply_topology_mutation( fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Result<()> { active_subject(state, subject)?; if state.occupancy.contains_key(subject) + || state.occupancy.values().any(|place| place == subject) || state.custody.contains_key(subject) || state.custody.values().any(|owner| owner == subject) || state.memberships.iter().any(|(key, value)| { @@ -2406,6 +2519,18 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul || state.commitments.iter().any(|(key, commitment)| { &key.subject == subject || commitment.counterparty.as_ref() == Some(subject) }) + || state + .knowledge + .keys() + .any(|key| &key.knower == subject || &key.proposition == subject) + || state + .place_profiles + .values() + .any(|profile| profile.container.as_ref() == Some(subject)) + || state + .propositions + .values() + .any(|proposition| proposition.discoverable_at_places.contains(subject)) { return Err(anyhow!( "entity retirement would leave active component references" @@ -2418,6 +2543,8 @@ fn retire_entity(state: &mut ComponentWorldState, subject: &SubjectRef) -> Resul .expect("active subject was resolved"); record.lifecycle = LifecycleStatus::Retired; record.version = version; + state.place_profiles.remove(subject); + state.propositions.remove(subject); Ok(()) } @@ -2530,12 +2657,93 @@ pub fn validate_component_world(state: &ComponentWorldState) -> Result<()> { active_subject(state, observer)?; } } + for (place, profile) in &state.place_profiles { + let subject = active_subject(state, place)?; + require_kind(place, SubjectKind::Place)?; + if &profile.place != place + || profile.name.trim().is_empty() + || !subject + .admitted_components + .contains(&WorldComponentKind::PlaceProfile) + || profile + .persistent_features + .iter() + .any(|feature| feature.trim().is_empty()) + { + return Err(anyhow!("place profile is malformed")); + } + if let Some(container) = &profile.container { + active_subject(state, container)?; + require_kind(container, SubjectKind::Place)?; + if container == place { + return Err(anyhow!("place cannot contain itself")); + } + } + } + for (subject, record) in &state.subjects { + if matches!( + record.lifecycle, + LifecycleStatus::Admitted | LifecycleStatus::Active + ) && subject.kind == SubjectKind::Place + && !state.place_profiles.contains_key(subject) + { + return Err(anyhow!("active place lacks its canonical profile")); + } + } + for start in state.place_profiles.keys() { + let mut seen = BTreeSet::new(); + let mut cursor = Some(start); + while let Some(place) = cursor { + if !seen.insert(place.clone()) { + return Err(anyhow!("place containment contains a cycle")); + } + cursor = state + .place_profiles + .get(place) + .and_then(|profile| profile.container.as_ref()); + } + } + let mut proposition_statements = BTreeSet::new(); + for (proposition, value) in &state.propositions { + let subject = active_subject(state, proposition)?; + require_kind(proposition, SubjectKind::Proposition)?; + if &value.proposition != proposition + || value.statement.trim().is_empty() + || !proposition_statements.insert(value.statement.clone()) + || !subject + .admitted_components + .contains(&WorldComponentKind::PropositionContent) + || value + .evidence_receipt_ids + .iter() + .any(|receipt_id| receipt_id.trim().is_empty()) + { + return Err(anyhow!("proposition content is malformed")); + } + for place in &value.discoverable_at_places { + active_subject(state, place)?; + require_kind(place, SubjectKind::Place)?; + } + } + for (subject, record) in &state.subjects { + if matches!( + record.lifecycle, + LifecycleStatus::Admitted | LifecycleStatus::Active + ) && subject.kind == SubjectKind::Proposition + && !state.propositions.contains_key(subject) + { + return Err(anyhow!("active proposition lacks canonical content")); + } + } for edge in state.topology.values() { active_subject(state, &edge.from_place)?; active_subject(state, &edge.to_place)?; require_kind(&edge.from_place, SubjectKind::Place)?; require_kind(&edge.to_place, SubjectKind::Place)?; - if edge.travel_minutes <= 0 || edge.from_place == edge.to_place { + if edge.distance.trim().is_empty() + || edge.travel_minutes <= 0 + || edge.from_place == edge.to_place + { return Err(anyhow!("topology edge is malformed")); } } @@ -2646,14 +2854,24 @@ fn mutation_component_refs( AdmitEntity { subject, initial_place, + initial_profile, .. - } => std::iter::once((subject.clone(), Component::Lifecycle, None)) - .chain( - initial_place - .iter() - .map(|_| (subject.clone(), Component::Occupancy, None)), - ) - .collect(), + } => { + let mut refs = vec![(subject.clone(), Component::Lifecycle, None)]; + if initial_place.is_some() { + refs.push((subject.clone(), Component::Occupancy, None)); + } + match initial_profile { + Some(AdmittedEntityProfile::Place { .. }) => { + refs.push((subject.clone(), Component::PlaceProfile, None)); + } + Some(AdmittedEntityProfile::Proposition { .. }) => { + refs.push((subject.clone(), Component::PropositionContent, None)); + } + None => {} + } + refs + } RetireEntity { subject, .. } => vec![(subject.clone(), Component::Lifecycle, None)], AdvanceWorldTime { campaign, .. } => vec![(campaign.clone(), Component::WorldTime, None)], } @@ -2746,6 +2964,20 @@ fn permit_schema_branch( permit: &MutationPermit, variants: &[serde_json::Value], ) -> Result { + if let Some(exact) = &permit.exact_mutation { + if exact.operation() != permit.operation { + return Err(anyhow!("exact mutation permit operation is inconsistent")); + } + return Ok(serde_json::json!({ + "type":"object", + "additionalProperties":false, + "required":["permit_id","mutation"], + "properties":{ + "permit_id":{"const":permit.id}, + "mutation":{"const":exact} + } + })); + } let (mutation_type, operation) = mutation_schema_tags(permit.operation); let mut mutation = variants .iter() @@ -3044,9 +3276,14 @@ fn string_schema_fields( (IdentityHandleId, "handle_id", Required), (IdentityHandleValue, "handle_value", Optional), ], - TopologyAdd | TopologyAlter | TopologyOpen | TopologyClose | TopologyRetire => { - vec![(TopologyEdgeId, "edge_id", Required)] - } + TopologyAdd | TopologyAlter => vec![ + (TopologyEdgeId, "edge_id", Required), + (Distance, "distance", Required), + ], + TopologyOpen | TopologyClose | TopologyRetire => vec![ + (TopologyEdgeId, "edge_id", Required), + (Distance, "distance", Optional), + ], AdmitEntity => vec![(AdmissionReceiptId, "admission_receipt_id", Required)], RetireEntity => vec![(RetirementReason, "reason", Required)], TransferCustody | KnowledgeAcquire | KnowledgeCommunicate | KnowledgeConceal @@ -3162,11 +3399,23 @@ fn mutation_schema_tags(operation: WorldMutationOperation) -> (&'static str, Opt } fn validate_permitted_mutation(permit: &MutationPermit, mutation: &WorldMutation) -> Result<()> { + if permit.operation == WorldMutationOperation::AdmitEntity && permit.exact_mutation.is_none() { + return Err(anyhow!( + "entity admission requires an exact typed mutation permit" + )); + } if mutation.operation() != permit.operation { return Err(anyhow!( "world mutation operation does not match its permit" )); } + if let Some(exact) = &permit.exact_mutation + && exact != mutation + { + return Err(anyhow!( + "world mutation differs from the exact semantic mutation admitted by its permit" + )); + } let roles = mutation.subject_roles(); for binding in &permit.subject_bindings { let actual = roles.get(&binding.role).cloned().unwrap_or_default(); @@ -3560,8 +3809,13 @@ impl WorldMutation { values.push((MutationStringRole::IdentityHandleValue, value.clone())); } } - ChangeTopology { edge_id, .. } => { - values.push((MutationStringRole::TopologyEdgeId, edge_id.clone())) + ChangeTopology { + edge_id, distance, .. + } => { + values.push((MutationStringRole::TopologyEdgeId, edge_id.clone())); + if let Some(value) = distance { + values.push((MutationStringRole::Distance, value.clone())); + } } AdmitEntity { admission_receipt_id, @@ -3823,6 +4077,7 @@ impl WorldMutation { ChangeTopology { operation, edge_id, + distance, travel_minutes, .. } => { @@ -3830,18 +4085,31 @@ impl WorldMutation { if matches!( operation, TopologyMutationOperation::Add | TopologyMutationOperation::Alter - ) && travel_minutes.is_none() + ) && (travel_minutes.is_none() + || distance + .as_deref() + .is_none_or(|value| value.trim().is_empty())) { return Err(anyhow!( - "topology creation or alteration requires travel time" + "topology creation or alteration requires distance and travel time" + )); + } + if !matches!( + operation, + TopologyMutationOperation::Add | TopologyMutationOperation::Alter + ) && distance.is_some() + { + return Err(anyhow!( + "topology open, close, or retire cannot rewrite distance" )); } } AdmitEntity { + subject, initial_components, initial_place, + initial_profile, admission_receipt_id, - .. } => { if initial_components.is_empty() { return Err(anyhow!("entity admission requires initial components")); @@ -3856,6 +4124,88 @@ impl WorldMutation { if let Some(place) = initial_place { require_kind(place, SubjectKind::Place)?; } + match (subject.kind.clone(), initial_profile) { + ( + SubjectKind::Place, + Some(AdmittedEntityProfile::Place { + name, + container, + persistent_features, + }), + ) => { + if !initial_components.contains(&WorldComponentKind::PlaceProfile) + || !initial_components.contains(&WorldComponentKind::Topology) + || initial_place.is_some() + { + return Err(anyhow!( + "place admission requires place-profile and topology components without occupancy" + )); + } + nonempty("place name", name)?; + if let Some(container) = container { + require_kind(container, SubjectKind::Place)?; + nonempty("place container id", &container.id)?; + if container == subject { + return Err(anyhow!("place cannot contain itself")); + } + } + if persistent_features + .iter() + .any(|feature| feature.trim().is_empty()) + { + return Err(anyhow!("place profile contains an empty feature")); + } + } + ( + SubjectKind::Proposition, + Some(AdmittedEntityProfile::Proposition { + statement, + evidence_receipt_ids, + discoverable_at_places, + .. + }), + ) => { + if !initial_components.contains(&WorldComponentKind::PropositionContent) + || !initial_components.contains(&WorldComponentKind::Knowledge) + || initial_place.is_some() + { + return Err(anyhow!( + "proposition admission requires proposition-content and knowledge components without occupancy" + )); + } + nonempty("proposition statement", statement)?; + for receipt_id in evidence_receipt_ids { + nonempty("proposition evidence receipt id", receipt_id)?; + } + for place in discoverable_at_places { + require_kind(place, SubjectKind::Place)?; + nonempty("proposition discovery place id", &place.id)?; + } + } + (SubjectKind::Place, _) => { + return Err(anyhow!("place admission requires an exact place profile")); + } + (SubjectKind::Proposition, _) => { + return Err(anyhow!( + "proposition admission requires exact proposition content" + )); + } + (_, Some(_)) => { + return Err(anyhow!( + "entity admission profile does not match the subject kind" + )); + } + (_, None) + if initial_components.contains(&WorldComponentKind::PlaceProfile) + || initial_components + .contains(&WorldComponentKind::PropositionContent) => + { + return Err(anyhow!( + "non-place/proposition admission cannot claim definition components" + )); + } + (_, None) => {} + } nonempty("admission receipt id", admission_receipt_id)?; } RetireEntity { reason, .. } => nonempty("retirement reason", reason)?, @@ -3918,6 +4268,7 @@ pub fn exact_mutation_permit( subject_bindings, string_constraints, integer_bounds, + exact_mutation: Some(mutation.clone()), maximum_uses: 1, }) } @@ -3968,6 +4319,7 @@ mod tests { exact(&["route:camp-east"]), )]), integer_bounds: BTreeMap::new(), + exact_mutation: None, maximum_uses: 1, } } @@ -4059,6 +4411,36 @@ mod tests { assert!(validate_permitted_mutation(&permit, &rewritten).is_err()); } + #[test] + fn exact_admission_permit_binds_the_complete_place_profile() { + let mutation = WorldMutation::AdmitEntity { + subject: subject(SubjectKind::Place, "place:annex"), + initial_components: BTreeSet::from([ + WorldComponentKind::PlaceProfile, + WorldComponentKind::Topology, + ]), + initial_place: None, + initial_profile: Some(AdmittedEntityProfile::Place { + name: "Annex".into(), + container: Some(subject(SubjectKind::Place, "place:camp")), + persistent_features: BTreeSet::from(["sealed gate".into()]), + }), + admission_receipt_id: "region-expansion:test".into(), + }; + let permit = exact_mutation_permit("permit:annex", &mutation).unwrap(); + validate_permitted_mutation(&permit, &mutation).unwrap(); + let mut rewritten = mutation; + let WorldMutation::AdmitEntity { + initial_profile: Some(AdmittedEntityProfile::Place { name, .. }), + .. + } = &mut rewritten + else { + unreachable!(); + }; + *name = "Conveniently Adjacent Dream Annex".into(); + assert!(validate_permitted_mutation(&permit, &rewritten).is_err()); + } + #[test] fn stale_or_partially_invalid_batch_has_no_structural_admission() { let envelope = envelope(); @@ -4139,6 +4521,7 @@ mod tests { (MutationStringRole::IdentityHandleValue, exact(&["Sable"])), ]), integer_bounds: BTreeMap::new(), + exact_mutation: None, maximum_uses: 1, }; let mutation = WorldMutation::ChangeIdentity { @@ -4182,13 +4565,24 @@ mod tests { medicine.clone(), proposition.clone(), ] { + let admitted_components = match &value.kind { + SubjectKind::Place => BTreeSet::from([ + WorldComponentKind::PlaceProfile, + WorldComponentKind::Topology, + ]), + SubjectKind::Proposition => BTreeSet::from([ + WorldComponentKind::Knowledge, + WorldComponentKind::PropositionContent, + ]), + _ => BTreeSet::new(), + }; subjects.insert( value.clone(), TypedSubject { schema: "ghostlight.typed_subject.v1".into(), subject: value, lifecycle: LifecycleStatus::Active, - admitted_components: BTreeSet::new(), + admitted_components, version: 4, }, ); @@ -4227,7 +4621,7 @@ mod tests { knowledge: BTreeMap::from([( KnowledgeKey { knower: sable, - proposition, + proposition: proposition.clone(), }, KnowledgeComponentState { status: "known".into(), @@ -4251,12 +4645,46 @@ mod tests { )]), population_lineages: BTreeMap::new(), identities: BTreeMap::new(), + place_profiles: BTreeMap::from([ + ( + camp.clone(), + PlaceComponentState { + place: camp.clone(), + name: "Camp".into(), + container: None, + persistent_features: BTreeSet::new(), + version: 4, + }, + ), + ( + subject(SubjectKind::Place, "place:trail"), + PlaceComponentState { + place: subject(SubjectKind::Place, "place:trail"), + name: "Trail".into(), + container: None, + persistent_features: BTreeSet::new(), + version: 4, + }, + ), + ]), + propositions: BTreeMap::from([( + proposition.clone(), + PropositionComponentState { + proposition, + statement: "The eastern trail exists.".into(), + scope: FactScope::CanonBaseline, + evidence_receipt_ids: BTreeSet::new(), + discoverable_at_places: BTreeSet::from([camp.clone()]), + version: 4, + }, + )]), topology: BTreeMap::from([( "route:camp-trail".into(), TopologyComponentState { id: "route:camp-trail".into(), from_place: camp, to_place: subject(SubjectKind::Place, "place:trail"), + distance: "near".into(), travel_minutes: 30, open: true, version: 4, @@ -4284,6 +4712,7 @@ mod tests { .collect(), string_constraints: strings.into_iter().collect(), integer_bounds: integers.into_iter().collect(), + exact_mutation: None, maximum_uses: 1, } }