From 122aa1fb3e0353acdfbcdc5edf5cd43fcf9b3927 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 23 Aug 2026 06:58:04 +0200 Subject: [PATCH] Preserve actor continuity in strategic cells --- .../src/bin/ghostlight-campaign-inspect.rs | 26 ++++++++++++++-- crates/ghostlight-dungeon/src/compiler.rs | 30 +++++++++++++++++-- crates/ghostlight-dungeon/src/kernel.rs | 2 +- crates/ghostlight-dungeon/src/persona.rs | 6 ++++ crates/ghostlight-dungeon/src/resolution.rs | 19 ++++++++---- crates/ghostlight-dungeon/src/scheduler.rs | 24 +++++++++++++++ 6 files changed, 94 insertions(+), 13 deletions(-) diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs index 88fad89..0b3a25e 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs @@ -96,7 +96,14 @@ fn main() -> Result<()> { for actor in campaign.actors.values() { subjects.insert( actor.id.clone(), - json!({ "kind": "actor", "name": actor.name }), + json!({ + "kind": "actor", + "name": actor.name, + "goals": actor.goals, + "obligations": actor.obligations, + "relationships": actor.relationships, + "memories": actor.memories, + }), ); } for institution in campaign.institutions.values() { @@ -108,13 +115,26 @@ fn main() -> Result<()> { for gestalt in campaign.gestalts.values() { subjects.insert( gestalt.id.clone(), - json!({ "kind": "gestalt", "name": gestalt.name }), + json!({ + "kind": "gestalt", + "name": gestalt.name, + "goals": gestalt.goals, + "pressures": gestalt.pressures, + }), ); } for member in campaign.gestalt_members.values() { subjects.insert( member.id.clone(), - json!({ "kind": "gestalt_member", "name": member.name, "gestaltId": member.gestalt_id }), + json!({ + "kind": "gestalt_member", + "name": member.name, + "gestaltId": member.gestalt_id, + "goals": member.goals, + "obligations": member.obligations, + "relationships": member.relationships, + "memories": member.memories, + }), ); } diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index df88268..863b205 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -598,7 +598,7 @@ impl WorldCompiler { .map(|brief| brief.subject_id.clone()) .collect::>(); let agency_prompt = format!( - "MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper claim. information_channels are routes through which the subject can publish or receive reports (for example a courier wire, bulletin, newspaper, radio net, or word of mouth), never facts the subject knows; do not copy knowledge_or_posture values into information_channels. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.", + "MULTIRESOLUTION AGENCY SKELETON\nCompile only this exact, already validated subject roster:\n{}\n\nReturn exactly one agency profile for every supplied subject and no other subject. Copy every subject_id, subject_kind, and location_ids exactly. Every profile must contain exactly the six facet axes geography, ideology, authority, economy_role, species_body, and information. Derive facets only from the supplied roster fields; use an explicit unknown value when they do not support a sharper facet claim. information_channels are concrete routes through which the subject can publish or receive reports (for example a courier wire, bulletin, newspaper, radio net, or word of mouth), never facts the subject knows; do not copy knowledge_or_posture values into information_channels, and use an empty set rather than an unknown placeholder when no route is supported. collective_authority_id must be null or one supplied subject ID; it denotes real shared authority, never mere alliance or proximity. Relations may use only supplied subject IDs and strength must be an integer from 1 through 100. Cross-faction relations never imply shared speech, knowledge, or authority. Preserve geographic, ideological, institutional, economic, biological, and information boundaries that predict different behavior under pressure.", serde_json::to_string(&subject_briefs)? ); let agency_schema = serde_json::to_value(schema_for!(CompiledAgencySkeleton))?; @@ -3041,17 +3041,24 @@ fn apply_compiled_agency_skeleton( .unwrap_or_default(), AgencySubjectKind::Institution => Vec::new(), }; + let invalid_information_channels = input + .information_channels + .iter() + .filter(|channel| !crate::resolution::information_channel_is_concrete(channel)) + .cloned() + .collect::>(); if profile.subject_kind != input.subject_kind || input_axes != axes || input.location_ids != profile.location_ids || !unknown_locations.is_empty() || !authority_known || !knowledge_channel_overlap.is_empty() + || !invalid_information_channels.is_empty() { let missing_axes = axes.difference(&input_axes).cloned().collect::>(); let unexpected_axes = input_axes.difference(&axes).cloned().collect::>(); return Err(anyhow!( - "agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}; knowledge_channel_overlap={knowledge_channel_overlap:?}", + "agency profile {} malformed: expected_kind={:?}; supplied_kind={:?}; expected_location_ids={:?}; supplied_location_ids={:?}; missing_axes={missing_axes:?}; unexpected_axes={unexpected_axes:?}; unknown_locations={unknown_locations:?}; unknown_collective_authority={:?}; knowledge_channel_overlap={knowledge_channel_overlap:?}; invalid_information_channels={invalid_information_channels:?}", input.subject_id, profile.subject_kind, input.subject_kind, @@ -3297,7 +3304,7 @@ mod tests { subject_id: "player".into(), subject_kind: AgencySubjectKind::Actor, collective_authority_id: None, - facets, + facets: facets.clone(), location_ids: BTreeSet::from(["center".into()]), information_channels: BTreeSet::from(["convoy vulnerabilities".into()]), }], @@ -3305,6 +3312,23 @@ mod tests { ) .unwrap_err(); assert!(error.to_string().contains("knowledge_channel_overlap")); + + campaign.actors.get_mut("player").unwrap().knowledge.clear(); + let error = apply_compiled_agency_skeleton( + &mut campaign, + &BTreeSet::from(["player".into()]), + vec![CompiledAgencyProfile { + subject_id: "player".into(), + subject_kind: AgencySubjectKind::Actor, + collective_authority_id: None, + facets, + location_ids: BTreeSet::from(["center".into()]), + information_channels: BTreeSet::from(["unknown".into()]), + }], + vec![], + ) + .unwrap_err(); + assert!(error.to_string().contains("invalid_information_channels")); } #[test] diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index be548f1..9011f96 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -2705,7 +2705,7 @@ fn validate_public_channels(channels: &[String]) -> Result<(), KernelError> { if channels.len() > 8 || channels .iter() - .any(|c| c.trim().is_empty() || c.len() > 160) + .any(|channel| !crate::resolution::information_channel_is_concrete(channel)) { return Err(KernelError::Invalid( "strategic action has invalid information channels".into(), diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index bd01b71..7280824 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -69,6 +69,8 @@ pub struct CellConstituentSlice { pub migration_destinations: BTreeMap, pub activity_target_ids: BTreeSet, pub goals: Vec, + pub relationships: BTreeMap, + pub memories: Vec, pub current_posture: Option, pub pressures: Vec, } @@ -450,6 +452,8 @@ fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value { "migration_destinations": subject.migration_destinations, "activity_target_ids": subject.activity_target_ids, "goals": subject.goals, + "relationships": subject.relationships, + "memories": subject.memories, "already_committed_posture": subject.current_posture, "pressures": subject.pressures, })).collect::>(), @@ -1993,6 +1997,8 @@ mod tests { migration_destinations: BTreeMap::new(), activity_target_ids: BTreeSet::new(), goals: vec!["publish a position".into()], + relationships: BTreeMap::new(), + memories: vec![], current_posture: Some("weighing whether to publish a position".into()), pressures: vec!["the vote is near".into()], }], diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index e717c02..769912c 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -8,6 +8,11 @@ pub const MIN_ACTIVE_CELL_BUDGET: u8 = 1; pub const MAX_ACTIVE_CELL_BUDGET: u8 = 128; pub const MAX_PROVIDER_PARALLELISM: u8 = 32; +pub(crate) fn information_channel_is_concrete(channel: &str) -> bool { + let channel = channel.trim(); + !channel.is_empty() && channel.len() <= 160 && !channel.eq_ignore_ascii_case("unknown") +} + #[derive(Clone, Debug)] struct Candidate { left: String, @@ -82,16 +87,17 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) { for actor in campaign.actors.values() { if let Some(profile) = campaign.agency_profiles.get_mut(&actor.id) { profile.location_ids = BTreeSet::from([actor.location_id.clone()]); - profile - .information_channels - .retain(|channel| !actor.knowledge.contains(channel)); + profile.information_channels.retain(|channel| { + information_channel_is_concrete(channel) && !actor.knowledge.contains(channel) + }); } } for gestalt in campaign.gestalts.values() { if let Some(profile) = campaign.agency_profiles.get_mut(&gestalt.id) { - profile - .information_channels - .retain(|channel| !gestalt.shared_knowledge.contains(channel)); + profile.information_channels.retain(|channel| { + information_channel_is_concrete(channel) + && !gestalt.shared_knowledge.contains(channel) + }); } } let live: BTreeSet<_> = campaign @@ -2952,6 +2958,7 @@ pub(crate) mod tests { .information_channels = BTreeSet::from([ "private convoy vulnerability".into(), "licensed courier wire".into(), + "unknown".into(), ]); ensure_agency_profiles(&mut value); assert_eq!( diff --git a/crates/ghostlight-dungeon/src/scheduler.rs b/crates/ghostlight-dungeon/src/scheduler.rs index 220f989..94d6565 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -730,6 +730,8 @@ fn constituent_slice(campaign: &Campaign, id: &str) -> Result Result