diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index 478613b..06473d4 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -45,30 +45,6 @@ async fn main() -> anyhow::Result<()> { )) }); std::fs::create_dir_all(&root)?; - let mut campaign = strategic_campaign(); - let pressure_event = ghostlight_dungeon::domain::Event { - id: format!("pressure-{}", uuid::Uuid::new_v4()), - at: campaign.world_time, - kind: "strategic_pressure".into(), - summary: pressure.clone(), - actor_ids: vec!["runner".into()], - institution_ids: vec!["board".into(), "synod".into()], - gestalt_ids: vec![], - location_ids: vec!["depot".into(), "yard".into()], - public_channels: vec!["root-wire broadsheet".into()], - }; - campaign.news.push(ghostlight_dungeon::domain::NewsIssue { - id: format!("news:{}:root-wire-broadsheet", pressure_event.id), - at: pressure_event.at, - channel: "root-wire broadsheet".into(), - headline: ghostlight_dungeon::domain::committed_news_headline(&pressure_event.summary), - event_ids: vec![pressure_event.id.clone()], - reliability: "committed public channel".into(), - }); - campaign.events.push(pressure_event); - let player_before = campaign.actors[&campaign.player_actor_id].clone(); - let store = CampaignStore::open(root.join("campaign.cc"))?; - store.create_unadmitted_fixture_campaign(&campaign, &[], &[])?; let model = model_selection.open()?.ok_or_else(|| { anyhow::anyhow!( "{} credential is unavailable at {}", @@ -76,6 +52,95 @@ async fn main() -> anyhow::Result<()> { model_selection.credential_path.display() ) })?; + let public_channel = admitted_public_channel( + &std::env::var("GHOSTLIGHT_STRATEGIC_PUBLIC_CHANNEL") + .unwrap_or_else(|_| "root-wire broadsheet".into()), + )?; + let compiled = std::env::var("GHOSTLIGHT_WORLD_DESCRIPTION") + .ok() + .filter(|description| !description.trim().is_empty()); + let (mut campaign, seed_evidence_receipts, seed_model_receipts, world_compile) = + if let Some(description) = compiled { + std::fs::write( + root.join("status.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema":"ghostlight.live_strategic_smoke_status.v1", + "state":"compiling_world", + "waves_completed":0, + "waves_requested":wave_count, + "updated_at":Utc::now(), + }))?, + )?; + let (preview, receipts) = compile_strategic_campaign( + model.clone(), + &model_selection.fast_model, + &model_selection.capable_model, + &description, + &pressure, + &public_channel, + ) + .await?; + std::fs::write( + root.join("compiler-preview.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "description":description, + "preview":&preview, + "model_receipts":&receipts, + }))?, + )?; + let evidence = preview.evidence_receipts.clone(); + let campaign = preview.campaign.clone(); + ( + campaign, + evidence, + receipts.clone(), + Some(serde_json::json!({ + "description":description, + "preview":preview, + "model_receipts":receipts, + "preview_path":root.join("compiler-preview.json"), + })), + ) + } else { + (strategic_campaign(), vec![], vec![], None) + }; + ghostlight_dungeon::compiler::validate_campaign_seed(&campaign)?; + let pressure_event = ghostlight_dungeon::domain::Event { + id: format!("pressure-{}", uuid::Uuid::new_v4()), + at: campaign.world_time, + kind: "strategic_pressure".into(), + summary: pressure.clone(), + actor_ids: campaign + .actors + .keys() + .filter(|actor_id| **actor_id != campaign.player_actor_id) + .cloned() + .collect(), + institution_ids: campaign.institutions.keys().cloned().collect(), + gestalt_ids: campaign.gestalts.keys().cloned().collect(), + location_ids: campaign.locations.keys().cloned().collect(), + public_channels: vec![public_channel.clone()], + }; + campaign.news.push(ghostlight_dungeon::domain::NewsIssue { + id: format!( + "news:{}:{}", + pressure_event.id, + public_channel.replace(' ', "-") + ), + at: pressure_event.at, + channel: public_channel, + headline: ghostlight_dungeon::domain::committed_news_headline(&pressure_event.summary), + event_ids: vec![pressure_event.id.clone()], + reliability: "committed public channel".into(), + }); + campaign.events.push(pressure_event); + let player_before = campaign.actors[&campaign.player_actor_id].clone(); + let store = CampaignStore::open(root.join("campaign.cc"))?; + store.create_unadmitted_fixture_campaign( + &campaign, + &seed_evidence_receipts, + &seed_model_receipts, + )?; let newspaper_title = std::env::var("GHOSTLIGHT_STRATEGIC_NEWSPAPER_TITLE") .unwrap_or_else(|_| "The Underdeep Clarion".into()); let newspaper_voice = std::env::var("GHOSTLIGHT_STRATEGIC_NEWSPAPER_VOICE") @@ -320,6 +385,7 @@ async fn main() -> anyhow::Result<()> { "campaign_id":campaign.id, "elapsed_seconds":started.elapsed().as_secs_f64(), "model_runtime":model_selection.status("configured"), + "world_compile":&world_compile, "model_receipt_hash":&final_model_receipt_hash, "model_stage_receipts":&model_stage_receipts, "plan":&final_plan, @@ -375,6 +441,7 @@ async fn main() -> anyhow::Result<()> { "campaign_id":campaign.id, "elapsed_seconds":started.elapsed().as_secs_f64(), "model_runtime":model_selection.status("configured"), + "world_compile":world_compile, "model_receipt_hash":final_model_receipt_hash, "model_stage_receipts":model_stage_receipts, "plan":final_plan, @@ -459,6 +526,94 @@ async fn compose_persisted_newspaper( result } +async fn compile_strategic_campaign( + model: std::sync::Arc, + retrieval_model: &str, + compiler_model: &str, + description: &str, + pressure: &str, + public_channel: &str, +) -> anyhow::Result<( + ghostlight_dungeon::domain::WorldCompilePreview, + Vec, +)> { + use ghostlight_dungeon::{ + compiler::{CustomStart, WorldCompiler, validate_campaign_seed}, + domain::SourceWitness, + vault::FixtureVault, + }; + use sha2::{Digest, Sha256}; + + let description = description.trim(); + if description.chars().count() > 8_000 { + anyhow::bail!("GHOSTLIGHT_WORLD_DESCRIPTION accepts at most 8,000 characters") + } + let world_name = std::env::var("GHOSTLIGHT_WORLD_NAME") + .unwrap_or_else(|_| "The Elven Realms Beyond the Greathold".into()); + let who = std::env::var("GHOSTLIGHT_WORLD_PLAYER").unwrap_or_else(|_| { + "The player-controlled Greathold, represented by a boundary observer; its sovereign choices remain external to the autonomous world simulation." + .into() + }); + let where_ = std::env::var("GHOSTLIGHT_WORLD_WHERE").unwrap_or_else(|_| { + "the inhabited realms immediately beyond the Greathold boundary described by the supplied setting source" + .into() + }); + let when = std::env::var("GHOSTLIGHT_WORLD_WHEN").unwrap_or_else(|_| { + "a strained late age before any single realm has secured hegemony".into() + }); + let goal = format!( + "Observe without ruling while the autonomous world responds to this new external pressure from the Greathold: {pressure}" + ); + let source_id = "consumer-setting-description"; + let witness = SourceWitness { + source_id: source_id.into(), + exact_locator: "consumer://setting-description".into(), + content_hash: format!("sha256:{:x}", Sha256::digest(description.as_bytes())), + excerpt: description.into(), + authority_lane: "consumer.setting_description".into(), + temporal_scope: when.clone(), + }; + let compiler = WorldCompiler::new( + std::sync::Arc::new(FixtureVault { + witnesses: vec![witness], + }), + model, + retrieval_model, + compiler_model, + ); + let (mut preview, receipts) = compiler + .compile_custom(CustomStart { + campaign_name: world_name, + who, + where_, + when, + goal, + }) + .await?; + let campaign = &mut preview.campaign; + campaign.resolution_policy.active_cell_budget = bounded_environment_usize( + "GHOSTLIGHT_STRATEGIC_CELL_BUDGET", + 200, + ghostlight_dungeon::resolution::MIN_ACTIVE_CELL_BUDGET as usize, + ghostlight_dungeon::resolution::MAX_ACTIVE_CELL_BUDGET as usize, + )? as u8; + ghostlight_dungeon::resolution::ensure_agency_profiles(campaign); + for (subject_id, profile) in &mut campaign.agency_profiles { + profile.simulation_eligible = subject_id != &campaign.player_actor_id; + profile.information_channels.insert(public_channel.into()); + } + validate_campaign_seed(campaign)?; + Ok((preview, receipts)) +} + +fn admitted_public_channel(value: &str) -> anyhow::Result { + let channel = value.trim(); + if !ghostlight_dungeon::resolution::information_channel_is_concrete(channel) { + anyhow::bail!("GHOSTLIGHT_STRATEGIC_PUBLIC_CHANNEL is not a concrete information route") + } + Ok(channel.into()) +} + fn bounded_environment_usize( name: &str, default: usize, @@ -509,13 +664,12 @@ fn strategic_campaign() -> ghostlight_dungeon::domain::Campaign { } } let now = Utc::now(); - let mut player = actor( + let player = actor( "player", "Deep-hold Envoy", "room", "observe without ruling", ); - player.knowledge.insert("root-wire broadsheet".into()); let mut campaign = Campaign { schema: "ghostlight.campaign.v1".into(), id: uuid::Uuid::new_v4(), @@ -711,7 +865,7 @@ fn strategic_campaign() -> ghostlight_dungeon::domain::Campaign { #[cfg(test)] mod tests { - use super::final_wave_field; + use super::{admitted_public_channel, final_wave_field, strategic_campaign}; #[test] fn top_level_projection_uses_the_final_wave_head() { @@ -723,4 +877,29 @@ mod tests { let commit = final_wave_field(&waves, "commit").unwrap(); assert_eq!(commit["campaign"]["revision"], 2); } + + #[test] + fn public_channel_requires_one_concrete_information_route() { + assert_eq!( + admitted_public_channel(" root-wire broadsheet ").unwrap(), + "root-wire broadsheet" + ); + for invalid in ["", " ", "unknown"] { + assert!(admitted_public_channel(invalid).is_err()); + } + assert!(admitted_public_channel(&"x".repeat(161)).is_err()); + } + + #[test] + fn static_fixture_obeys_the_same_channel_and_knowledge_invariant() { + let campaign = strategic_campaign(); + ghostlight_dungeon::compiler::validate_campaign_seed(&campaign).unwrap(); + let player = &campaign.actors[&campaign.player_actor_id]; + assert!(!player.knowledge.contains("root-wire broadsheet")); + assert!( + campaign.agency_profiles[&campaign.player_actor_id] + .information_channels + .contains("root-wire broadsheet") + ); + } } diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 2cd59cb..d4662ac 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -5219,6 +5219,35 @@ fn validate_campaign(c: &Campaign, require_dematerialized_members: bool) -> Resu "campaign agency profile does not match canonical subject {subject_id}" )); } + if let Some(channel) = profile + .information_channels + .iter() + .find(|channel| !crate::resolution::information_channel_is_concrete(channel)) + { + return Err(anyhow!( + "campaign agency profile {subject_id} has an invalid information channel {channel:?}" + )); + } + let knowledge_overlap = match expected_kind { + AgencySubjectKind::Actor => c.actors.get(subject_id).and_then(|actor| { + profile + .information_channels + .intersection(&actor.knowledge) + .next() + }), + AgencySubjectKind::Gestalt => c.gestalts.get(subject_id).and_then(|gestalt| { + profile + .information_channels + .intersection(&gestalt.shared_knowledge) + .next() + }), + AgencySubjectKind::Institution => None, + }; + if let Some(channel) = knowledge_overlap { + return Err(anyhow!( + "campaign agency profile {subject_id} cannot use factual knowledge {channel:?} as an information channel" + )); + } } if let Some(profile) = c.agency_profiles.values().find(|profile| { profile.active_leaf @@ -6777,6 +6806,40 @@ mod tests { assert!(error.to_string().contains("one to 460 characters")); } + #[test] + fn campaign_seed_rejects_invalid_channels_and_knowledge_disguised_as_channels() { + let mut campaign = crate::resolution::tests::campaign(2, 1); + campaign + .agency_profiles + .get_mut("player") + .unwrap() + .information_channels + .insert("unknown".into()); + let error = validate_campaign_seed(&campaign).unwrap_err(); + assert!(error.to_string().contains("invalid information channel")); + + campaign + .agency_profiles + .get_mut("player") + .unwrap() + .information_channels + .remove("unknown"); + campaign + .agency_profiles + .get_mut("player") + .unwrap() + .information_channels + .insert("root-wire broadsheet".into()); + campaign + .actors + .get_mut("player") + .unwrap() + .knowledge + .insert("root-wire broadsheet".into()); + let error = validate_campaign_seed(&campaign).unwrap_err(); + assert!(error.to_string().contains("cannot use factual knowledge")); + } + #[test] fn approved_relationships_resolve_party_members_and_materialize_stable_private_anchors() { let mut sable = CharacterDraft { diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 64d23f2..068a149 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -2981,6 +2981,7 @@ fn apply_strategic_tick_plan( activity_means .get(&action.action_digest) .map(String::as_str), + &action.public_channels, ), actor_ids, institution_ids, @@ -3162,6 +3163,7 @@ fn apply_strategic_tick_plan( activity_means .get(&action.action_digest) .map(String::as_str), + &action.public_channels, ), actor_ids, institution_ids, @@ -3288,6 +3290,7 @@ fn apply_strategic_tick_plan( activity_means .get(&action.action_digest) .map(String::as_str), + &action.public_channels, ), actor_ids, institution_ids, @@ -3498,12 +3501,16 @@ fn strategic_activity_summary( activity: &StrategicActivityKind, target_names: &[String], admitted_means: Option<&str>, + public_channels: &[String], ) -> String { if let Some(means) = admitted_means .map(str::trim) .filter(|means| !means.is_empty()) { let means = means.trim_end_matches(['.', '!', '?']); + if !public_channels.is_empty() { + return format!("Public statement from {source_name}: {means}."); + } let mut characters = means.chars(); let lowered = characters .next() @@ -6241,6 +6248,23 @@ pub(crate) mod tests { ); } + #[test] + fn durable_publication_reports_the_published_course_not_an_unfinished_publication_attempt() { + let summary = strategic_activity_summary( + "Thornweald Charcoal Guilds", + &StrategicActivityKind::Communicate, + &["Copper Synod".into()], + Some("Publish the caravan evidence and call every forest kiln to readiness"), + &["root-wire broadsheet".into()], + ); + + assert_eq!( + summary, + "Public statement from Thornweald Charcoal Guilds: Publish the caravan evidence and call every forest kiln to readiness." + ); + assert!(!summary.contains("attempt")); + } + #[test] fn strategic_outcome_materializes_a_bounded_resource_from_exact_capability() { let mut value = hierarchical_refugee_campaign(); diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index d465f8f..48d034c 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -11,7 +11,9 @@ pub const MIN_ACTIVE_CELL_BUDGET: u8 = 1; pub const MAX_ACTIVE_CELL_BUDGET: u8 = 240; pub const MAX_PROVIDER_PARALLELISM: u8 = 32; -pub(crate) fn information_channel_is_concrete(channel: &str) -> bool { +/// True when a publication or reception route is concrete enough to enter an +/// agency profile. Knowledge belongs to the subject state, never this lane. +pub fn information_channel_is_concrete(channel: &str) -> bool { let channel = channel.trim(); !channel.is_empty() && channel.len() <= 160 && !channel.eq_ignore_ascii_case("unknown") }