diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs index 2e9d14a..88fad89 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-campaign-inspect.rs @@ -1,8 +1,7 @@ use anyhow::{Context, Result}; use ghostlight_dungeon::{ domain::{ - Campaign, CellAppraisal, ResolutionCover, StrategicActivityOutcome, - StrategicTickReceipt, + Campaign, CellAppraisal, ResolutionCover, StrategicActivityOutcome, StrategicTickReceipt, }, model::ModelStageReceipt, persistence::CampaignStore, @@ -34,9 +33,7 @@ fn main() -> Result<()> { (Some(revision), Some(epoch)) => store .load_all::("resolution_cover.v1")? .into_iter() - .find(|cover| { - cover.world_revision == revision && cover.resolution_epoch == epoch - }), + .find(|cover| cover.world_revision == revision && cover.resolution_epoch == epoch), _ => None, }; let appraisals = match (snapshot_revision, resolution_epoch) { @@ -55,10 +52,8 @@ fn main() -> Result<()> { let mut outcomes = Vec::new(); for key in store.keys("strategic_activity_outcome.v1")? { if key.starts_with(&prefix) - && let Some((_, outcome)) = store.load::( - "strategic_activity_outcome.v1", - &key, - )? + && let Some((_, outcome)) = store + .load::("strategic_activity_outcome.v1", &key)? { outcomes.push(outcome); } @@ -99,7 +94,10 @@ fn main() -> Result<()> { let mut subjects = BTreeMap::new(); for actor in campaign.actors.values() { - subjects.insert(actor.id.clone(), json!({ "kind": "actor", "name": actor.name })); + subjects.insert( + actor.id.clone(), + json!({ "kind": "actor", "name": actor.name }), + ); } for institution in campaign.institutions.values() { subjects.insert( diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index ee4e884..df88268 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. 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 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.", serde_json::to_string(&subject_briefs)? ); let agency_schema = serde_json::to_value(schema_for!(CompiledAgencySkeleton))?; @@ -3016,16 +3016,42 @@ fn apply_compiled_agency_skeleton( .filter(|id| !campaign.locations.contains_key(*id)) .cloned() .collect::>(); + let knowledge_channel_overlap = match input.subject_kind { + AgencySubjectKind::Actor => campaign + .actors + .get(&input.subject_id) + .map(|actor| { + input + .information_channels + .intersection(&actor.knowledge) + .cloned() + .collect::>() + }) + .unwrap_or_default(), + AgencySubjectKind::Gestalt => campaign + .gestalts + .get(&input.subject_id) + .map(|gestalt| { + input + .information_channels + .intersection(&gestalt.shared_knowledge) + .cloned() + .collect::>() + }) + .unwrap_or_default(), + AgencySubjectKind::Institution => Vec::new(), + }; 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() { 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={:?}", + "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:?}", input.subject_id, profile.subject_kind, input.subject_kind, @@ -3244,6 +3270,43 @@ fn validate_opening_playability(campaign: &Campaign) -> Result<()> { mod tests { use super::*; + #[test] + fn agency_compiler_cannot_disguise_private_knowledge_as_a_channel() { + let mut campaign = crate::resolution::tests::campaign(0, 1); + campaign + .actors + .get_mut("player") + .unwrap() + .knowledge + .insert("convoy vulnerabilities".into()); + let facets = BTreeMap::from([ + (AgencyAxis::Geography, BTreeSet::from(["center".into()])), + (AgencyAxis::Ideology, BTreeSet::from(["unknown".into()])), + (AgencyAxis::Authority, BTreeSet::from(["unknown".into()])), + (AgencyAxis::EconomyRole, BTreeSet::from(["unknown".into()])), + (AgencyAxis::SpeciesBody, BTreeSet::from(["unknown".into()])), + ( + AgencyAxis::Information, + BTreeSet::from(["convoy vulnerabilities".into()]), + ), + ]); + 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(["convoy vulnerabilities".into()]), + }], + vec![], + ) + .unwrap_err(); + assert!(error.to_string().contains("knowledge_channel_overlap")); + } + #[test] fn user_text_admission_rejects_empty_oversized_and_binary_control_input() { assert!(validate_user_text("field", "", 8).is_err()); @@ -3661,7 +3724,7 @@ mod tests { }).to_string() } "agency_compile" => serde_json::json!({ - "agency_profiles":[{"subject_id":"yard-workers","subject_kind":"gestalt","collective_authority_id":"yard-workers","facets":{"geography":["yard"],"ideology":["mutual aid"],"authority":["yard-workers"],"economy_role":["maintenance"],"species_body":["human"],"information":["yard routines"]},"location_ids":["yard"],"information_channels":["yard routines"]}], + "agency_profiles":[{"subject_id":"yard-workers","subject_kind":"gestalt","collective_authority_id":"yard-workers","facets":{"geography":["yard"],"ideology":["mutual aid"],"authority":["yard-workers"],"economy_role":["maintenance"],"species_body":["human"],"information":["yard routines"]},"location_ids":["yard"],"information_channels":["yard bulletin"]}], "agency_relations":[] }).to_string(), _ => return Err(anyhow!("unexpected stage")), diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index ebb85f1..e717c02 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -84,7 +84,14 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) { profile.location_ids = BTreeSet::from([actor.location_id.clone()]); profile .information_channels - .extend(actor.knowledge.iter().cloned()); + .retain(|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)); } } let live: BTreeSet<_> = campaign @@ -143,7 +150,6 @@ fn profile_for_actor(actor: &ActorState, evidence: &[String]) -> AgencyProfile { AgencyAxis::Information, actor.knowledge.iter().cloned().collect(), ); - profile.information_channels = actor.knowledge.iter().cloned().collect(); profile } @@ -185,7 +191,6 @@ fn profile_for_gestalt(value: &GestaltPersonaState, evidence: &[String]) -> Agen AgencyAxis::Information, value.shared_knowledge.iter().cloned().collect(), ); - profile.information_channels = value.shared_knowledge.iter().cloned().collect(); profile } @@ -2922,6 +2927,39 @@ pub(crate) mod tests { value } + #[test] + fn profile_maintenance_never_promotes_knowledge_into_public_channels() { + let mut value = campaign(1, 1); + value + .actors + .get_mut("player") + .unwrap() + .knowledge + .insert("private convoy vulnerability".into()); + + value.agency_profiles.remove("player"); + ensure_agency_profiles(&mut value); + assert!( + value.agency_profiles["player"] + .information_channels + .is_empty() + ); + + value + .agency_profiles + .get_mut("player") + .unwrap() + .information_channels = BTreeSet::from([ + "private convoy vulnerability".into(), + "licensed courier wire".into(), + ]); + ensure_agency_profiles(&mut value); + assert_eq!( + value.agency_profiles["player"].information_channels, + BTreeSet::from(["licensed courier wire".into()]) + ); + } + #[test] fn aetheria_scale_cover_is_complete_unique_and_budgeted() { for budget in [1, 4, 8, 32] {