diff --git a/crates/ghostlight-dungeon/src/agent.rs b/crates/ghostlight-dungeon/src/agent.rs index 3c1d52a..07f6320 100644 --- a/crates/ghostlight-dungeon/src/agent.rs +++ b/crates/ghostlight-dungeon/src/agent.rs @@ -42,6 +42,10 @@ pub struct ModelAgentToolContext { /// the action was produced; they are not source evidence for canonical /// world facts or profiles. pub source_receipt_ids: Vec, + /// The receipt for the model action currently being executed. Tools that + /// admit a narrower semantic object may rebind this receipt to that exact + /// object and return the rebound receipt with their accepted output. + pub current_model_receipt: Option, } pub enum ModelAgentToolOutcome { @@ -196,6 +200,7 @@ pub async fn run_model_agent( .chain(std::iter::once(stage.receipt.clone())) .collect::>(), ), + current_model_receipt: Some(stage.receipt.clone()), }; match tool.invoke(action, &tool_context).await { ModelAgentToolOutcome::Continue { diff --git a/crates/ghostlight-dungeon/src/assessor.rs b/crates/ghostlight-dungeon/src/assessor.rs index f125988..6e3b398 100644 --- a/crates/ghostlight-dungeon/src/assessor.rs +++ b/crates/ghostlight-dungeon/src/assessor.rs @@ -3891,6 +3891,7 @@ mod tests { progress: 1, threshold: 4, consequence: "The clinic fails.".into(), + consequence_scope: Default::default(), }, ); campaign.institutions.insert( @@ -4345,6 +4346,7 @@ mod tests { progress: 3, threshold: 4, consequence: "The regulator fails.".into(), + consequence_scope: Default::default(), }, ); let acting = campaign.actors["player"].clone(); diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs index ed24e9e..cc3ab30 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs @@ -163,7 +163,7 @@ async fn main() -> anyhow::Result<()> { fn action_campaign() -> ghostlight_dungeon::domain::Campaign { use chrono::Utc; use ghostlight_dungeon::domain::{ - ActorState, BranchOrigin, Campaign, InstitutionState, Location, WorldClock, + ActorState, BranchOrigin, Campaign, InstitutionState, Location, WorldClock, WorldEventScope, }; use std::collections::{BTreeMap, BTreeSet}; @@ -245,6 +245,13 @@ fn action_campaign() -> ghostlight_dungeon::domain::Campaign { progress: 2, threshold: 5, consequence: "the coolant loop trips offline".into(), + consequence_scope: WorldEventScope { + actor_ids: vec!["foreman".into()], + institution_ids: vec!["station-operations".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["maintenance-bay".into()], + public_channels: Vec::new(), + }, }, )]), facts: BTreeMap::new(), diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs index 7aea701..2926b0a 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -1085,6 +1085,13 @@ fn dynamics_campaign() -> ghostlight_dungeon::domain::Campaign { progress: 3, threshold: 4, consequence: "the camp is cut off by the storm".into(), + consequence_scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["port-authority".into(), "relief-union".into()], + gestalt_ids: vec!["refugees-east".into(), "relief-crews".into()], + location_ids: vec!["transit-camp".into(), "south-harbor".into()], + public_channels: vec!["public harbor bulletin".into()], + }, }, )]), facts: BTreeMap::new(), diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs index 840291d..7f3907e 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs @@ -259,6 +259,13 @@ fn scale_campaign( progress: 5, threshold: 6, consequence: "the governing coalition is chosen".into(), + consequence_scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: (0..8).map(|index| format!("faction-{index:02}")).collect(), + gestalt_ids: Vec::new(), + location_ids: vec!["forum".into()], + public_channels: vec!["public bulletin".into()], + }, }, )]), facts: BTreeMap::new(), diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index a0fafcc..d8b3411 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -27,6 +27,22 @@ struct FoundationCheckpoint { model_receipts: Vec, } +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ClockBindingProposalCheckpoint { + schema: String, + admission: ghostlight_dungeon::clock::ClockConsequenceBindingAdmission, + model_receipts: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ClockBindingCheckpoint { + schema: String, + binding_receipt: ghostlight_dungeon::clock::ClockConsequenceBindingReceipt, + commit_receipt: ghostlight_dungeon::domain::WorldCommitReceipt, +} + #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct CompletedInvocationCheckpoint { @@ -215,6 +231,85 @@ fn load_checkpoint_receipts( .collect() } +fn latest_clock_binding_receipt( + store: &ghostlight_dungeon::persistence::CampaignStore, + campaign_id: uuid::Uuid, +) -> anyhow::Result> { + Ok(store + .load_all::( + "clock_consequence_binding_receipt.v1", + )? + .into_iter() + .filter(|receipt| receipt.campaign_id == campaign_id) + .max_by_key(|receipt| receipt.revision)) +} + +fn validate_clock_binding_receipt_projection( + campaign: &ghostlight_dungeon::domain::Campaign, + receipt: &ghostlight_dungeon::clock::ClockConsequenceBindingReceipt, +) -> anyhow::Result<()> { + if receipt.schema != "ghostlight.clock_consequence_binding_receipt.v1" + || receipt.campaign_id != campaign.id + || receipt.previous_revision.saturating_add(1) != receipt.revision + || receipt.revision > campaign.revision + || receipt.bindings.iter().any(|binding| { + campaign + .clocks + .get(&binding.clock_id) + .is_none_or(|clock| clock.consequence_scope != binding.scope) + }) + || receipt + .emitted_event_ids + .iter() + .any(|id| !campaign.events.iter().any(|event| &event.id == id)) + || receipt + .emitted_news_ids + .iter() + .any(|id| !campaign.news.iter().any(|news| &news.id == id)) + { + anyhow::bail!("canonical clock consequence binding receipt does not match the campaign") + } + Ok(()) +} + +fn recover_committed_clock_binding( + store: &ghostlight_dungeon::persistence::CampaignStore, + campaign: &ghostlight_dungeon::domain::Campaign, + checkpoint_path: &std::path::Path, +) -> anyhow::Result> { + let Some(binding_receipt) = latest_clock_binding_receipt(store, campaign.id)? else { + if checkpoint_path.is_file() { + anyhow::bail!("clock binding checkpoint has no canonical CultCache receipt") + } + return Ok(None); + }; + validate_clock_binding_receipt_projection(campaign, &binding_receipt)?; + let commit_receipt = store + .load::( + "world_commit_receipt.v1", + &format!("{}-{}", campaign.id, binding_receipt.revision), + )? + .map(|(_, receipt)| receipt) + .ok_or_else(|| anyhow::anyhow!("clock binding world commit receipt is missing"))?; + let checkpoint = ClockBindingCheckpoint { + schema: "ghostlight.clock_consequence_binding_checkpoint.v2".into(), + binding_receipt: binding_receipt.clone(), + commit_receipt, + }; + if checkpoint_path.is_file() { + let persisted: ClockBindingCheckpoint = read_checkpoint(checkpoint_path)?; + if persisted.schema != checkpoint.schema + || persisted.binding_receipt != checkpoint.binding_receipt + || persisted.commit_receipt != checkpoint.commit_receipt + { + anyhow::bail!("clock binding checkpoint disagrees with canonical CultCache state") + } + } else { + publish_immutable_checkpoint(checkpoint_path, &checkpoint)?; + } + Ok(Some(binding_receipt)) +} + fn rehydrate_titled_failure( checkpoint: TitledFailureCheckpoint, store: &ghostlight_dungeon::persistence::CampaignStore, @@ -469,7 +564,7 @@ async fn main() -> anyhow::Result<()> { "The sovereign deep-hold diverted the White Root aquifer. Two tithe caravans have vanished, the charcoal guilds threaten secession, and somebody pawned the regent's rain seal." .into() }); - let wave_count = bounded_environment_usize("GHOSTLIGHT_STRATEGIC_WAVES", 1, 1, 8)?; + let wave_count = bounded_environment_usize("GHOSTLIGHT_STRATEGIC_WAVES", 1, 1, 16)?; let max_rejected_pulses_per_wave = bounded_environment_usize("GHOSTLIGHT_STRATEGIC_MAX_REJECTED_PULSES_PER_WAVE", 2, 0, 4)?; let root = std::env::var_os("GHOSTLIGHT_LIVE_FIRE_RESULT_ROOT") @@ -596,7 +691,11 @@ async fn main() -> anyhow::Result<()> { let initial_location_ids = campaign.locations.keys().cloned().collect::>(); (campaign, vec![], vec![], None, initial_location_ids) }; - ghostlight_dungeon::compiler::validate_campaign_seed(&campaign)?; + if resume { + ghostlight_dungeon::compiler::validate_campaign_runtime(&campaign)?; + } else { + ghostlight_dungeon::compiler::validate_campaign_seed(&campaign)?; + } if !resume { let pressure_event = ghostlight_dungeon::domain::Event { id: format!("pressure-{}", uuid::Uuid::new_v4()), @@ -614,19 +713,7 @@ async fn main() -> anyhow::Result<()> { 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.clone(), - 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); + ghostlight_dungeon::domain::append_event_with_publications(&mut campaign, pressure_event); store.create_unadmitted_fixture_campaign( &campaign, &seed_evidence_receipts, @@ -635,6 +722,103 @@ async fn main() -> anyhow::Result<()> { } let player_before = campaign.actors[&campaign.player_actor_id].clone(); let kernel = WorldKernel::start(store.clone()); + let clock_binding_path = root.join("clock-consequence-binding.json"); + let clock_binding_proposal_path = root.join("clock-consequence-binding-proposal.json"); + let mut pending_clock_news_start = None; + if resume + && campaign + .clocks + .values() + .any(|clock| clock.consequence_scope.is_unbound()) + { + if clock_binding_path.is_file() { + anyhow::bail!( + "clock consequence binding checkpoint exists but the persisted campaign is still unbound" + ) + } + std::fs::write( + root.join("status.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema":"ghostlight.live_strategic_smoke_status.v1", + "state":"binding_clock_consequences", + "waves_completed":campaign.strategic_tick_count, + "waves_requested":wave_count, + "world_revision":campaign.revision, + "updated_at":Utc::now(), + }))?, + )?; + let proposal = if clock_binding_proposal_path.is_file() { + read_checkpoint::(&clock_binding_proposal_path)? + } else { + let binding_run = ghostlight_dungeon::clock::propose_clock_consequence_bindings( + model.as_ref(), + &campaign, + ) + .await + .map_err(anyhow::Error::new)?; + let proposal = ClockBindingProposalCheckpoint { + schema: "ghostlight.clock_consequence_binding_proposal_checkpoint.v1".into(), + admission: binding_run.output, + model_receipts: binding_run.receipts, + }; + publish_immutable_checkpoint(&clock_binding_proposal_path, &proposal)?; + proposal + }; + if proposal.schema != "ghostlight.clock_consequence_binding_proposal_checkpoint.v1" { + anyhow::bail!("clock consequence binding proposal checkpoint is unsupported") + } + ghostlight_dungeon::clock::validate_binding_receipts( + &campaign, + &proposal.admission, + &proposal.model_receipts, + )?; + let CommandResult::Committed { + campaign: bound_campaign, + receipt, + } = kernel + .command(WorldCommand::BindClockConsequences { + expected_revision: campaign.revision, + admission: proposal.admission, + model_stage_receipts: proposal.model_receipts, + }) + .await? + else { + anyhow::bail!("clock consequence binding did not commit") + }; + let binding_receipt = store + .load::( + "clock_consequence_binding_receipt.v1", + &format!("{}-{}", bound_campaign.id, bound_campaign.revision), + )? + .map(|(_, receipt)| receipt) + .ok_or_else(|| anyhow::anyhow!("clock binding commit lacks its canonical receipt"))?; + validate_clock_binding_receipt_projection(&bound_campaign, &binding_receipt)?; + publish_immutable_checkpoint( + &clock_binding_path, + &ClockBindingCheckpoint { + schema: "ghostlight.clock_consequence_binding_checkpoint.v2".into(), + binding_receipt: binding_receipt.clone(), + commit_receipt: receipt, + }, + )?; + pending_clock_news_start = Some(( + binding_receipt.next_wave_index, + binding_receipt.news_count_before, + )); + campaign = bound_campaign; + } else if resume + && let Some(binding_receipt) = + recover_committed_clock_binding(&store, &campaign, &clock_binding_path)? + { + if campaign.strategic_tick_count.saturating_add(1) as usize + == binding_receipt.next_wave_index + { + pending_clock_news_start = Some(( + binding_receipt.next_wave_index, + binding_receipt.news_count_before, + )); + } + } let elaboration_passes = if compiled.is_some() { bounded_environment_usize("GHOSTLIGHT_WORLD_ELABORATION_PASSES", 0, 0, 8)? } else { @@ -1221,11 +1405,16 @@ async fn main() -> anyhow::Result<()> { } let completed_wave_count = wave_reports.len(); for wave_index in completed_wave_count + 1..=wave_count { - let previous_news_count = if wave_index == 1 { - 0 - } else { - campaign.news.len() - }; + let previous_news_count = pending_clock_news_start + .filter(|(next_wave_index, _)| *next_wave_index == wave_index) + .map(|(_, news_count)| news_count) + .unwrap_or_else(|| { + if wave_index == 1 { + 0 + } else { + campaign.news.len() + } + }); let mut rejected_pulses = Vec::new(); let mut rejected_pulse_count = 0; for pulse in 1..=max_rejected_pulses_per_wave + 1 { @@ -1732,6 +1921,14 @@ async fn compile_strategic_campaign( profile.simulation_eligible = subject_id != &campaign.player_actor_id; profile.information_channels.insert(public_channel.into()); } + for clock in campaign.clocks.values_mut() { + if clock.consequence_scope.public_channels.is_empty() { + clock + .consequence_scope + .public_channels + .push(public_channel.into()); + } + } validate_campaign_seed(campaign)?; Ok((preview, receipts)) } @@ -1982,6 +2179,13 @@ fn strategic_campaign() -> ghostlight_dungeon::domain::Campaign { progress: 1, threshold: 4, consequence: "the charcoal guilds declare the regent ritually rainless".into(), + consequence_scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["board".into(), "synod".into()], + gestalt_ids: vec!["workers".into()], + location_ids: vec!["yard".into()], + public_channels: Vec::new(), + }, }, )]), facts: BTreeMap::new(), @@ -2074,8 +2278,9 @@ mod tests { use super::{ admitted_public_channel, civic_manifest_is_committed_candidate, committed_elaboration_mutation_proof, final_wave_field, latest_partial_wave_checkpoint, - publish_immutable_checkpoint, strategic_campaign, strategic_locality_request, - strategic_titled_locality_request, titled_failure_checkpoint_paths, + publish_immutable_checkpoint, recover_committed_clock_binding, strategic_campaign, + strategic_locality_request, strategic_titled_locality_request, + titled_failure_checkpoint_paths, }; fn civic_manifest( @@ -2122,6 +2327,101 @@ mod tests { assert!(admitted_public_channel(&"x".repeat(161)).is_err()); } + #[test] + fn missing_post_commit_checkpoint_is_rebuilt_from_cultcache() { + let directory = tempfile::tempdir().unwrap(); + let store = ghostlight_dungeon::persistence::CampaignStore::open( + directory.path().join("campaign.cc"), + ) + .unwrap(); + let mut campaign = strategic_campaign(); + campaign.revision = 15; + campaign.strategic_tick_count = 6; + campaign + .clocks + .get_mut("shortage") + .unwrap() + .consequence_scope + .public_channels = vec!["root-wire broadsheet".into()]; + let event = ghostlight_dungeon::domain::Event { + id: "clock-consequence:shortage".into(), + at: campaign.world_time, + kind: "clock_consequence".into(), + summary: campaign.clocks["shortage"].consequence.clone(), + actor_ids: Vec::new(), + institution_ids: vec!["board".into(), "synod".into()], + gestalt_ids: vec!["workers".into()], + location_ids: vec!["yard".into()], + public_channels: vec!["root-wire broadsheet".into()], + }; + ghostlight_dungeon::domain::append_event_with_publications(&mut campaign, event); + let committed_at = chrono::Utc::now(); + let binding_receipt = ghostlight_dungeon::clock::ClockConsequenceBindingReceipt { + schema: "ghostlight.clock_consequence_binding_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision: 14, + revision: 15, + snapshot_binding: "fixture-snapshot".into(), + binding_batch_digest: "sha256:fixture-batch".into(), + bindings: vec![ghostlight_dungeon::clock::ClockConsequenceBinding { + clock_id: "shortage".into(), + scope: campaign.clocks["shortage"].consequence_scope.clone(), + }], + model_receipt_ids: vec!["sha256:fixture-model".into()], + accepted_model_receipt_id: "sha256:fixture-model".into(), + emitted_event_ids: vec!["clock-consequence:shortage".into()], + emitted_news_ids: vec![ghostlight_dungeon::domain::event_publication_id( + "clock-consequence:shortage", + "root-wire broadsheet", + )], + news_count_before: 0, + next_wave_index: 7, + committed_at, + }; + let world_receipt = ghostlight_dungeon::domain::WorldCommitReceipt { + schema: "ghostlight.world_commit_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision: 14, + revision: 15, + command_kind: "bind_clock_consequences".into(), + committed_at, + roll: None, + }; + let key = format!("{}-15", campaign.id); + store + .insert( + "clock_consequence_binding_receipt.v1", + "ghostlight.clock_consequence_binding_receipt.v1", + &key, + &binding_receipt, + ) + .unwrap(); + store + .insert( + "world_commit_receipt.v1", + "ghostlight.world_commit_receipt.v1", + &key, + &world_receipt, + ) + .unwrap(); + let checkpoint_path = directory.path().join("clock-consequence-binding.json"); + + let recovered = recover_committed_clock_binding(&store, &campaign, &checkpoint_path) + .unwrap() + .unwrap(); + + assert!(checkpoint_path.is_file()); + assert_eq!(recovered.next_wave_index, 7); + assert_eq!(recovered.news_count_before, 0); + assert_eq!( + recovered.emitted_news_ids, + [ghostlight_dungeon::domain::event_publication_id( + "clock-consequence:shortage", + "root-wire broadsheet" + )] + ); + } + #[test] fn locality_elaboration_request_names_the_existing_place_and_stays_bounded() { let request = strategic_locality_request( diff --git a/crates/ghostlight-dungeon/src/clock.rs b/crates/ghostlight-dungeon/src/clock.rs new file mode 100644 index 0000000..543b41b --- /dev/null +++ b/crates/ghostlight-dungeon/src/clock.rs @@ -0,0 +1,739 @@ +use crate::{ + agent::{ModelAgentSpec, ModelAgentTool, ModelAgentToolContext, ModelAgentToolOutcome}, + domain::{Campaign, Event, WorldEventScope}, + model::{MODEL_BALANCED, ModelPort, ModelStageReceipt}, +}; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use uuid::Uuid; + +pub const CLOCK_CONSEQUENCE_BINDING_STAGE: &str = "clock_consequence_binding_agent_action"; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ClockConsequenceBinding { + pub clock_id: String, + pub scope: WorldEventScope, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ClockConsequenceBindingAdmission { + pub schema: String, + pub campaign_id: Uuid, + pub expected_revision: u64, + pub snapshot_binding: String, + pub binding_batch_digest: String, + pub bindings: Vec, + pub accepted_model_receipt_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct ClockConsequenceBindingReceipt { + pub schema: String, + pub campaign_id: Uuid, + pub previous_revision: u64, + pub revision: u64, + pub snapshot_binding: String, + pub binding_batch_digest: String, + pub bindings: Vec, + pub model_receipt_ids: Vec, + pub accepted_model_receipt_id: String, + pub emitted_event_ids: Vec, + pub emitted_news_ids: Vec, + pub news_count_before: usize, + pub next_wave_index: usize, + pub committed_at: DateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ClockConsequenceBindingAction { + bindings: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(deny_unknown_fields)] +struct ClockConsequenceBindingFinding { + diagnostic: String, +} + +struct ClockConsequenceBindingTool<'a> { + campaign: &'a Campaign, + schema: serde_json::Value, +} + +#[async_trait] +impl ModelAgentTool for ClockConsequenceBindingTool<'_> { + type Action = ClockConsequenceBindingAction; + type Output = ClockConsequenceBindingAdmission; + type Finding = ClockConsequenceBindingFinding; + + fn action_schema(&self) -> std::result::Result { + Ok(self.schema.clone()) + } + + async fn invoke( + &mut self, + action: Self::Action, + context: &ModelAgentToolContext, + ) -> ModelAgentToolOutcome { + match validate_clock_consequence_bindings(self.campaign, &action.bindings) { + Ok(()) => { + let Some(mut accepted_receipt) = context.current_model_receipt.clone() else { + return ModelAgentToolOutcome::Failed { + message: "clock binding tool lacks the current model receipt".into(), + receipts: Vec::new(), + }; + }; + let snapshot_binding = match clock_consequence_binding_snapshot(self.campaign) { + Ok(binding) => binding, + Err(error) => { + return ModelAgentToolOutcome::Failed { + message: error.to_string(), + receipts: Vec::new(), + }; + } + }; + let binding_batch_digest = + match clock_consequence_binding_batch_digest(self.campaign, &action.bindings) { + Ok(digest) => digest, + Err(error) => { + return ModelAgentToolOutcome::Failed { + message: error.to_string(), + receipts: Vec::new(), + }; + } + }; + accepted_receipt.rebind_snapshot(clock_consequence_admission_binding( + &snapshot_binding, + &binding_batch_digest, + )); + let accepted_model_receipt_id = accepted_receipt.storage_key().to_owned(); + ModelAgentToolOutcome::Accepted { + output: ClockConsequenceBindingAdmission { + schema: "ghostlight.clock_consequence_binding_admission.v1".into(), + campaign_id: self.campaign.id, + expected_revision: self.campaign.revision, + snapshot_binding, + binding_batch_digest, + bindings: action.bindings, + accepted_model_receipt_id, + }, + receipts: vec![accepted_receipt], + } + } + Err(error) => ModelAgentToolOutcome::Rejected { + finding: ClockConsequenceBindingFinding { + diagnostic: error.to_string(), + }, + receipts: Vec::new(), + }, + } + } +} + +pub fn clock_consequence_binding_snapshot(campaign: &Campaign) -> Result { + crate::legacy_transition::digest_serializable(&serde_json::json!({ + "campaign_id":campaign.id, + "revision":campaign.revision, + "clocks":campaign.clocks, + "actor_ids":campaign.actors.keys().collect::>(), + "institution_ids":campaign.institutions.keys().collect::>(), + "gestalt_ids":campaign.gestalts.keys().collect::>(), + "locations":campaign.locations.iter().map(|(id, location)| serde_json::json!({ + "id":id, + "name":location.name, + })).collect::>(), + "public_channels":admitted_public_channels(campaign), + })) +} + +pub async fn propose_clock_consequence_bindings( + model: &dyn ModelPort, + campaign: &Campaign, +) -> std::result::Result< + crate::agent::ModelAgentRun, + crate::agent::ModelAgentFailure, +> { + let unbound = campaign + .clocks + .values() + .filter(|clock| clock.consequence_scope.is_unbound()) + .collect::>(); + if unbound.is_empty() { + return Err(crate::agent::ModelAgentFailure { + message: "clock binding agent was invoked without unbound clocks".into(), + receipts: Vec::new(), + }); + } + let projection = serde_json::json!({ + "clocks":unbound, + "actors":campaign.actors.values().filter(|actor| stable_simulation_actor(campaign, &actor.id)).map(|actor| serde_json::json!({ + "id":actor.id, + "name":actor.name, + "location_id":actor.location_id, + })).collect::>(), + "institutions":campaign.institutions.values().filter(|institution| simulation_eligible_subject(campaign, &institution.id)).map(|institution| serde_json::json!({ + "id":institution.id, + "name":institution.name, + "posture":institution.posture, + })).collect::>(), + "gestalts":campaign.gestalts.values().filter(|gestalt| simulation_eligible_subject(campaign, &gestalt.id)).map(|gestalt| serde_json::json!({ + "id":gestalt.id, + "name":gestalt.name, + "home_location_id":gestalt.home_location_id, + "pressures":gestalt.pressures, + })).collect::>(), + "locations":campaign.locations.values().map(|location| serde_json::json!({ + "id":location.id, + "name":location.name, + })).collect::>(), + "public_channels":admitted_public_channels(campaign), + }); + let instructions = format!( + "You are the autonomous clock-consequence binding worker. The world and each clock's consequence are frozen. Bind every supplied clock exactly once to the existing people, institutions, populations, places, and public information routes that would actually experience or learn its declared consequence. This is scope reconciliation, not invention: do not rewrite consequences, add entities, include the player-controlled subject, or indiscriminately select the whole world. Choose the smallest causally sufficient scope. A public material crisis should normally include at least one exact place, one affected or responsible subject, and one supplied public channel so subsequent autonomous cells can perceive and respond to it. Return the complete binding batch through the typed tool. The deterministic tool will reject unknown, duplicate, empty, or overbroad references.\n\nFROZEN WORLD PROJECTION:\n{}", + serde_json::to_string(&projection).map_err(|error| crate::agent::ModelAgentFailure { + message: error.to_string(), + receipts: Vec::new(), + })? + ); + let mut schema = + serde_json::to_value(schema_for!(ClockConsequenceBindingAction)).map_err(|error| { + crate::agent::ModelAgentFailure { + message: error.to_string(), + receipts: Vec::new(), + } + })?; + crate::model_connector::project_strict_responses_schema(&mut schema).map_err(|error| { + crate::agent::ModelAgentFailure { + message: error.to_string(), + receipts: Vec::new(), + } + })?; + let binding = clock_consequence_binding_snapshot(campaign).map_err(|error| { + crate::agent::ModelAgentFailure { + message: error.to_string(), + receipts: Vec::new(), + } + })?; + let spec = ModelAgentSpec { + stage: CLOCK_CONSEQUENCE_BINDING_STAGE.into(), + model: MODEL_BALANCED.into(), + snapshot_binding: binding, + instructions, + source_receipt_ids: Vec::new(), + temperature: Some(0.1), + max_output_tokens: Some(3_000), + max_steps: 3, + }; + let mut tool = ClockConsequenceBindingTool { campaign, schema }; + crate::agent::run_model_agent(model, &spec, &mut tool).await +} + +pub fn clock_consequence_binding_batch_digest( + campaign: &Campaign, + bindings: &[ClockConsequenceBinding], +) -> Result { + crate::legacy_transition::digest_serializable(&serde_json::json!({ + "schema":"ghostlight.clock_consequence_binding_batch.v1", + "campaign_id":campaign.id, + "expected_revision":campaign.revision, + "bindings":bindings, + })) +} + +pub fn clock_consequence_admission_binding( + snapshot_binding: &str, + binding_batch_digest: &str, +) -> String { + format!("{snapshot_binding}:clock-consequence-binding-batch:{binding_batch_digest}") +} + +pub fn validate_clock_consequence_bindings( + campaign: &Campaign, + bindings: &[ClockConsequenceBinding], +) -> Result<()> { + let expected = campaign + .clocks + .values() + .filter(|clock| clock.consequence_scope.is_unbound()) + .map(|clock| clock.id.as_str()) + .collect::>(); + let supplied = bindings + .iter() + .map(|binding| binding.clock_id.as_str()) + .collect::>(); + if supplied.len() != bindings.len() || supplied != expected { + return Err(anyhow!( + "clock consequence bindings must name every unbound clock exactly once; expected {expected:?}, received {supplied:?}" + )); + } + for binding in bindings { + validate_clock_consequence_scope(campaign, &binding.scope)?; + } + Ok(()) +} + +pub fn validate_clock_consequence_scope( + campaign: &Campaign, + scope: &WorldEventScope, +) -> Result<()> { + if scope.is_unbound() || scope.location_ids.is_empty() { + return Err(anyhow!( + "a clock consequence requires an exact affected location and observable scope" + )); + } + if scope.actor_ids.is_empty() + && scope.institution_ids.is_empty() + && scope.gestalt_ids.is_empty() + { + return Err(anyhow!( + "a clock consequence requires at least one affected actor, institution, or population" + )); + } + for (kind, values, maximum) in [ + ("actor", &scope.actor_ids, 8usize), + ("institution", &scope.institution_ids, 8usize), + ("population", &scope.gestalt_ids, 8usize), + ("location", &scope.location_ids, 8usize), + ("public channel", &scope.public_channels, 8usize), + ] { + if values.len() > maximum || values.iter().collect::>().len() != values.len() { + return Err(anyhow!( + "clock consequence {kind} scope is duplicate or overbroad" + )); + } + } + if scope + .actor_ids + .iter() + .any(|id| !stable_simulation_actor(campaign, id)) + || scope + .institution_ids + .iter() + .any(|id| !simulation_eligible_subject(campaign, id)) + || scope + .gestalt_ids + .iter() + .any(|id| !simulation_eligible_subject(campaign, id)) + { + return Err(anyhow!( + "clock consequence scope cannot bind an externally controlled or ephemeral subject" + )); + } + if scope + .actor_ids + .iter() + .any(|id| !campaign.actors.contains_key(id)) + || scope + .institution_ids + .iter() + .any(|id| !campaign.institutions.contains_key(id)) + || scope + .gestalt_ids + .iter() + .any(|id| !campaign.gestalts.contains_key(id)) + || scope + .location_ids + .iter() + .any(|id| !campaign.locations.contains_key(id)) + { + return Err(anyhow!( + "clock consequence scope references an unknown subject or place" + )); + } + let channels = admitted_public_channels(campaign); + if scope + .public_channels + .iter() + .any(|channel| !channels.contains(channel)) + { + return Err(anyhow!( + "clock consequence scope references an unadmitted public channel" + )); + } + Ok(()) +} + +fn simulation_eligible_subject(campaign: &Campaign, subject_id: &str) -> bool { + campaign + .agency_profiles + .get(subject_id) + .is_none_or(|profile| profile.simulation_eligible) +} + +fn stable_simulation_actor(campaign: &Campaign, actor_id: &str) -> bool { + actor_id != campaign.player_actor_id + && simulation_eligible_subject(campaign, actor_id) + && !campaign + .gestalt_members + .values() + .any(|member| member.materialized_actor_id.as_deref() == Some(actor_id)) +} + +pub fn apply_clock_consequence_bindings( + campaign: &mut Campaign, + bindings: &[ClockConsequenceBinding], +) -> Result> { + validate_clock_consequence_bindings(campaign, bindings)?; + for binding in bindings { + campaign + .clocks + .get_mut(&binding.clock_id) + .expect("binding set was validated") + .consequence_scope = binding.scope.clone(); + } + materialize_due_clock_consequences(campaign) +} + +/// Deterministically derives the once-only canonical event owned by a clock's +/// first threshold crossing. Callers invoke this after any admitted mutation +/// batch so every command path shares the same trigger behavior. +pub fn materialize_due_clock_consequences(campaign: &mut Campaign) -> Result> { + let mut events = Vec::new(); + for clock in campaign.clocks.values() { + if clock.progress < clock.threshold || clock.consequence_scope.is_unbound() { + continue; + } + validate_clock_consequence_scope(campaign, &clock.consequence_scope)?; + let event_id = format!("clock-consequence:{}", clock.id); + if campaign.events.iter().any(|event| event.id == event_id) { + continue; + } + let event = Event { + id: event_id.clone(), + at: campaign.world_time, + kind: "clock_consequence".into(), + summary: clock.consequence.trim().to_owned(), + actor_ids: clock.consequence_scope.actor_ids.clone(), + institution_ids: clock.consequence_scope.institution_ids.clone(), + gestalt_ids: clock.consequence_scope.gestalt_ids.clone(), + location_ids: clock.consequence_scope.location_ids.clone(), + public_channels: clock.consequence_scope.public_channels.clone(), + }; + events.push(event); + } + let emitted = events + .iter() + .map(|event| event.id.clone()) + .collect::>(); + for event in events { + crate::domain::append_event_with_publications(campaign, event); + } + Ok(emitted) +} + +fn admitted_public_channels(campaign: &Campaign) -> BTreeSet { + campaign + .agency_profiles + .values() + .flat_map(|profile| profile.information_channels.iter().cloned()) + .chain(campaign.news.iter().map(|issue| issue.channel.clone())) + .filter(|channel| crate::resolution::information_channel_is_concrete(channel)) + .collect() +} + +pub fn validate_binding_receipts( + campaign: &Campaign, + admission: &ClockConsequenceBindingAdmission, + receipts: &[ModelStageReceipt], +) -> Result<()> { + let snapshot = clock_consequence_binding_snapshot(campaign)?; + let binding_batch_digest = + clock_consequence_binding_batch_digest(campaign, &admission.bindings)?; + if admission.schema != "ghostlight.clock_consequence_binding_admission.v1" + || admission.campaign_id != campaign.id + || admission.expected_revision != campaign.revision + || admission.snapshot_binding != snapshot + || admission.binding_batch_digest != binding_batch_digest + { + return Err(anyhow!( + "clock consequence binding admission does not match the frozen campaign and exact batch" + )); + } + validate_clock_consequence_bindings(campaign, &admission.bindings)?; + let expected_receipt_binding = + clock_consequence_admission_binding(&snapshot, &binding_batch_digest); + let accepted = receipts + .iter() + .find(|receipt| receipt.storage_key() == admission.accepted_model_receipt_id) + .ok_or_else(|| anyhow!("clock consequence binding lacks its accepted model receipt"))?; + let mut rebound = accepted.clone(); + rebound.rebind_snapshot(accepted.snapshot_binding.clone()); + if accepted.schema != "ghostlight.persona_stage_receipt.v1" + || accepted.stage != CLOCK_CONSEQUENCE_BINDING_STAGE + || accepted.snapshot_binding != expected_receipt_binding + || accepted.validation_result != "valid" + || accepted.local_validation_error.is_some() + || rebound.storage_key() != accepted.storage_key() + { + return Err(anyhow!( + "clock consequence binding accepted-model receipt is invalid or not bound to the exact batch" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + #[test] + fn binding_agent_schema_is_provider_strict() { + let mut schema = serde_json::to_value(schema_for!(ClockConsequenceBindingAction)).unwrap(); + crate::model_connector::project_strict_responses_schema(&mut schema).unwrap(); + } + + fn bound_scope() -> WorldEventScope { + WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["court".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: vec!["court broadsheet".into()], + } + } + + fn accepted_admission( + campaign: &Campaign, + bindings: Vec, + ) -> (ClockConsequenceBindingAdmission, ModelStageReceipt) { + let snapshot_binding = clock_consequence_binding_snapshot(campaign).unwrap(); + let binding_batch_digest = + clock_consequence_binding_batch_digest(campaign, &bindings).unwrap(); + let mut receipt = ModelStageReceipt { + schema: "ghostlight.persona_stage_receipt.v1".into(), + receipt_hash: String::new(), + provider: "fixture".into(), + model: "fixture-terra".into(), + stage: CLOCK_CONSEQUENCE_BINDING_STAGE.into(), + snapshot_binding: String::new(), + request_hash: format!("sha256:{:x}", Sha256::digest(b"request")), + output_hash: format!("sha256:{:x}", Sha256::digest(b"output")), + source_receipt_ids: Vec::new(), + latency_ms: 1, + validation_result: "valid".into(), + local_validation_error: None, + input_chars: 1, + output_chars: 1, + provider_attempts: Vec::new(), + }; + receipt.rebind_snapshot(clock_consequence_admission_binding( + &snapshot_binding, + &binding_batch_digest, + )); + let admission = ClockConsequenceBindingAdmission { + schema: "ghostlight.clock_consequence_binding_admission.v1".into(), + campaign_id: campaign.id, + expected_revision: campaign.revision, + snapshot_binding, + binding_batch_digest, + bindings, + accepted_model_receipt_id: receipt.storage_key().to_owned(), + }; + (admission, receipt) + } + + #[test] + fn due_clock_emits_exactly_one_grounded_public_event() { + let mut campaign = crate::kernel::tests::campaign(); + campaign.institutions.insert( + "court".into(), + crate::domain::InstitutionState { + id: "court".into(), + name: "Court".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "watching".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut("court") + .unwrap() + .information_channels + .insert("court broadsheet".into()); + campaign.clocks.insert( + "coup".into(), + crate::domain::WorldClock { + id: "coup".into(), + label: "Coup".into(), + progress: 3, + threshold: 3, + consequence: "The palace guard arrests the regent at breakfast.".into(), + consequence_scope: bound_scope(), + }, + ); + + assert_eq!( + materialize_due_clock_consequences(&mut campaign).unwrap(), + vec!["clock-consequence:coup"] + ); + assert!( + materialize_due_clock_consequences(&mut campaign) + .unwrap() + .is_empty() + ); + assert_eq!( + campaign + .events + .iter() + .filter(|event| event.kind == "clock_consequence") + .count(), + 1 + ); + assert_eq!( + campaign.news.last().unwrap().event_ids, + ["clock-consequence:coup"] + ); + } + + #[test] + fn shared_mutation_application_owns_threshold_crossing() { + let mut campaign = crate::kernel::tests::campaign(); + campaign.institutions.insert( + "court".into(), + crate::domain::InstitutionState { + id: "court".into(), + name: "Court".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "watching".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut("court") + .unwrap() + .information_channels + .insert("court broadsheet".into()); + campaign.clocks.insert( + "coup".into(), + crate::domain::WorldClock { + id: "coup".into(), + label: "Coup".into(), + progress: 0, + threshold: 1, + consequence: "The palace guard arrests the regent at breakfast.".into(), + consequence_scope: bound_scope(), + }, + ); + let transition = crate::legacy_transition::lower_strategic_wave( + &campaign, + &crate::domain::StrategicTickPlan::default(), + "test-wave", + chrono::Utc::now() + chrono::Duration::minutes(1), + ) + .unwrap() + .unwrap(); + + let receipt = crate::legacy_transition::apply_lowered_transition( + &mut campaign, + &transition, + chrono::Utc::now(), + ) + .unwrap(); + + assert_eq!(campaign.clocks["coup"].progress, 1); + assert_eq!(receipt.derived_event_ids, ["clock-consequence:coup"]); + assert!(campaign.events.iter().any(|event| { + event.id == "clock-consequence:coup" && event.kind == "clock_consequence" + })); + assert!( + campaign + .news + .iter() + .any(|news| news.event_ids == ["clock-consequence:coup"]) + ); + } + + #[test] + fn accepted_receipt_cannot_authorize_a_different_binding_batch() { + let mut campaign = crate::kernel::tests::campaign(); + campaign.institutions.insert( + "court".into(), + crate::domain::InstitutionState { + id: "court".into(), + name: "Court".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "watching".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut("court") + .unwrap() + .information_channels + .extend(["court broadsheet".into(), "court gazette".into()]); + campaign.clocks.insert( + "coup".into(), + crate::domain::WorldClock { + id: "coup".into(), + label: "Coup".into(), + progress: 0, + threshold: 1, + consequence: "The palace guard arrests the regent at breakfast.".into(), + consequence_scope: WorldEventScope::default(), + }, + ); + let bindings = vec![ClockConsequenceBinding { + clock_id: "coup".into(), + scope: bound_scope(), + }]; + let (mut admission, receipt) = accepted_admission(&campaign, bindings); + validate_binding_receipts(&campaign, &admission, std::slice::from_ref(&receipt)).unwrap(); + + admission.bindings[0].scope.public_channels = vec!["court gazette".into()]; + assert!( + validate_binding_receipts(&campaign, &admission, &[receipt]) + .unwrap_err() + .to_string() + .contains("exact batch") + ); + } + + #[test] + fn consequence_scope_rejects_externally_controlled_subjects() { + let mut campaign = crate::kernel::tests::campaign(); + campaign.institutions.insert( + "greathold".into(), + crate::domain::InstitutionState { + id: "greathold".into(), + name: "Greathold".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "player controlled".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut campaign); + campaign + .agency_profiles + .get_mut("greathold") + .unwrap() + .simulation_eligible = false; + let scope = WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["greathold".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: Vec::new(), + }; + + assert!( + validate_clock_consequence_scope(&campaign, &scope) + .unwrap_err() + .to_string() + .contains("externally controlled") + ); + } +} diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 537baa7..656e2d6 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -1232,7 +1232,7 @@ impl WorldCompiler { .transpose()? .unwrap_or_default(); let base_prompt = format!( - "{shared_prefix}{approved_contract_context}{player_identity_context}{operational_playability_context}Compile a bounded playable region with stable topology, local actors, populations, clocks, and only those remote institutions that have a direct causal relationship to this requested start. SCOPED EVIDENCE contains direct_seed witnesses only. Setting-background and excluded witnesses remain visible in the approval coverage but are deliberately absent here: they cannot donate cast, incidents, clocks, location state, goals, or institutional posture to this branch. Use evidence as canon constraints, not as an exhaustive game map. A source marked with a `.gm_canon` authority lane may constrain hidden canonical state, but it must not be quoted or paraphrased into opening narration or granted as player or NPC knowledge merely because this compiler received it. When the Vault omits game-scale geometry, routes, local people, procedures, or daily texture, invent the smallest coherent playable elaboration, mark facts branch_local or provisional_local, and disclose consequential choices in branch_assumptions. An unevidenced route needed to connect the bounded region is branch-local geometry, not an evidence gap. Intentionally unresolved identities or population detail remain unresolved branch assumptions, not material gaps. Safely omitted remote candidates are global coverage limits, not material gaps. The `gaps` array is legal only when no compatible elaboration can preserve an exact approved premise clause without choosing between contradictory canon baselines, inventing an unanchored baseline explicitly required by the premise, or exceeding an approved capability. Every gap must name that exact premise clause and the exact choice requiring table approval. `The Vault does not specify X` is never sufficient. Use an empty gaps array when branch-local invention preserves the premise. Never borrow a nearby story to fill a gap. Do not eagerly materialize remote settlements or people outside the bounded playable region. Private character history, secrets, relationships, and relationship subjects are deliberately absent and compile in a separate private stage; do not assume or reconstruct them. Emit only supported canon facts. A canon_baseline fact must cite one or more exact receipt_id values printed in SCOPED EVIDENCE whose witnesses directly support the whole statement. Never label an invented proper noun canon. Facts that an actor can uncover through an admitted local observation must exist before play and list the exact discoverable_at_location_ids where that observation is possible. Seed enough branch_local or provisional_local discoverable facts to make the requested opening pressure and immediate goal actionable; at least one such non-canon fact must be discoverable at the player's exact starting location. The later action assessor can reveal an existing fact but cannot invent one. Facts that are private history or not directly observable have an empty discovery-location set. The singular player object is the sole human-controlled subject projection. Never repeat its id, name, role, representative, or proxy in actors or gestalt_members; actors contains autonomous nonplayer individuals only. The player location and every actor location must exist. Containment describes nested geometry; it never creates implicit movement. Every supplied location is a playable occupancy node. When the region contains more than one location, explicit route chains must let the player reach every supplied location from the starting location and return. Model inaccessible scenery as a persistent feature instead of an unreachable location. Every route record needs a stable route_id within its origin, an exact supplied destination_id, a distance, and positive travel_minutes. Every fact discovery location must exist, clocks need positive thresholds, and the player id must be unique. Actor relationship records must use subject_id values copied from exact actor, institution, gestalt, or named-member subject IDs declared in this candidate, never display names, roles, undeclared groups, or location IDs. A relationship to a collective population names its exact gestalt; it does not union that population's knowledge or turn the actor into its authority. Represent populations that can act collectively (villages, crews, crowds, departments, corporations) as gestalt Personas. Seed a small roster of plausible durable member identities for people the player may encounter; member deltas contain only departures from their gestalt baseline and begin dematerialized. Do not duplicate a gestalt member in actors. Keep named plot-critical people as ordinary actors. Every gestalt home location and member gestalt reference must exist. Do not emit agency profiles or relations; those are compiled from the exact validated subject roster in the next stage." + "{shared_prefix}{approved_contract_context}{player_identity_context}{operational_playability_context}Compile a bounded playable region with stable topology, local actors, populations, clocks, and only those remote institutions that have a direct causal relationship to this requested start. SCOPED EVIDENCE contains direct_seed witnesses only. Setting-background and excluded witnesses remain visible in the approval coverage but are deliberately absent here: they cannot donate cast, incidents, clocks, location state, goals, or institutional posture to this branch. Use evidence as canon constraints, not as an exhaustive game map. A source marked with a `.gm_canon` authority lane may constrain hidden canonical state, but it must not be quoted or paraphrased into opening narration or granted as player or NPC knowledge merely because this compiler received it. When the Vault omits game-scale geometry, routes, local people, procedures, or daily texture, invent the smallest coherent playable elaboration, mark facts branch_local or provisional_local, and disclose consequential choices in branch_assumptions. An unevidenced route needed to connect the bounded region is branch-local geometry, not an evidence gap. Intentionally unresolved identities or population detail remain unresolved branch assumptions, not material gaps. Safely omitted remote candidates are global coverage limits, not material gaps. The `gaps` array is legal only when no compatible elaboration can preserve an exact approved premise clause without choosing between contradictory canon baselines, inventing an unanchored baseline explicitly required by the premise, or exceeding an approved capability. Every gap must name that exact premise clause and the exact choice requiring table approval. `The Vault does not specify X` is never sufficient. Use an empty gaps array when branch-local invention preserves the premise. Never borrow a nearby story to fill a gap. Do not eagerly materialize remote settlements or people outside the bounded playable region. Private character history, secrets, relationships, and relationship subjects are deliberately absent and compile in a separate private stage; do not assume or reconstruct them. Emit only supported canon facts. A canon_baseline fact must cite one or more exact receipt_id values printed in SCOPED EVIDENCE whose witnesses directly support the whole statement. Never label an invented proper noun canon. Facts that an actor can uncover through an admitted local observation must exist before play and list the exact discoverable_at_location_ids where that observation is possible. Seed enough branch_local or provisional_local discoverable facts to make the requested opening pressure and immediate goal actionable; at least one such non-canon fact must be discoverable at the player's exact starting location. The later action assessor can reveal an existing fact but cannot invent one. Facts that are private history or not directly observable have an empty discovery-location set. The singular player object is the sole human-controlled subject projection. Never repeat its id, name, role, representative, or proxy in actors or gestalt_members; actors contains autonomous nonplayer individuals only. The player location and every actor location must exist. Containment describes nested geometry; it never creates implicit movement. Every supplied location is a playable occupancy node. When the region contains more than one location, explicit route chains must let the player reach every supplied location from the starting location and return. Model inaccessible scenery as a persistent feature instead of an unreachable location. Every route record needs a stable route_id within its origin, an exact supplied destination_id, a distance, and positive travel_minutes. Every fact discovery location must exist, clocks need positive thresholds, and the player id must be unique. Every clock consequence_scope must name the smallest exact non-player actors, institutions, populations, and locations that experience its declared consequence; it must include at least one supplied location and one supplied autonomous subject. Leave public_channels empty during compilation unless an exact admitted route is supplied later by the consumer. Actor relationship records must use subject_id values copied from exact actor, institution, gestalt, or named-member subject IDs declared in this candidate, never display names, roles, undeclared groups, or location IDs. A relationship to a collective population names its exact gestalt; it does not union that population's knowledge or turn the actor into its authority. Represent populations that can act collectively (villages, crews, crowds, departments, corporations) as gestalt Personas. Seed a small roster of plausible durable member identities for people the player may encounter; member deltas contain only departures from their gestalt baseline and begin dematerialized. Do not duplicate a gestalt member in actors. Keep named plot-critical people as ordinary actors. Every gestalt home location and member gestalt reference must exist. Do not emit agency profiles or relations; those are compiled from the exact validated subject roster in the next stage." ); let schema = serde_json::to_value(schema_for!(CompiledSeed))?; let sources = receipt_ids_for_coverage(&receipts, &evidence_coverage); @@ -6588,6 +6588,7 @@ fn compiled_seed_structure(seed: &CompiledSeed) -> serde_json::Value { "id": clock.id, "progress": clock.progress, "threshold": clock.threshold, + "consequence_scope": clock.consequence_scope, })).collect::>(), "fact_ids": seed.facts.iter().map(|fact| fact.id.as_str()).collect::>(), }) @@ -6823,11 +6824,11 @@ pub fn validate_campaign_seed(c: &Campaign) -> Result<()> { validate_campaign(c, true) } -pub(crate) fn validate_campaign_runtime(c: &Campaign) -> Result<()> { +pub fn validate_campaign_runtime(c: &Campaign) -> Result<()> { validate_campaign(c, false) } -fn validate_campaign(c: &Campaign, require_dematerialized_members: bool) -> Result<()> { +fn validate_campaign(c: &Campaign, fresh_seed: bool) -> Result<()> { if c.tick_hours == 0 { return Err(anyhow!("strategic tick duration must be positive")); } @@ -6960,7 +6961,7 @@ fn validate_campaign(c: &Campaign, require_dematerialized_members: bool) -> Resu member.gestalt_id )); } - if require_dematerialized_members && member.materialized_actor_id.is_some() { + if fresh_seed && member.materialized_actor_id.is_some() { return Err(anyhow!( "compiled gestalt member {} must begin dematerialized", member.id @@ -7021,9 +7022,19 @@ fn validate_campaign(c: &Campaign, require_dematerialized_members: bool) -> Resu } } for clock in c.clocks.values() { - if clock.threshold == 0 || clock.progress > clock.threshold { + if clock.threshold == 0 + || clock.progress > clock.threshold + || (fresh_seed && clock.progress >= clock.threshold) + { return Err(anyhow!("clock {} is invalid", clock.id)); } + if clock.consequence_scope.is_unbound() { + if fresh_seed { + return Err(anyhow!("new clock {} has no consequence scope", clock.id)); + } + } else { + crate::clock::validate_clock_consequence_scope(c, &clock.consequence_scope)?; + } } validate_campaign_civic_systems(c)?; Ok(()) @@ -8656,7 +8667,7 @@ mod tests { "actors":[], "gestalts":[{"schema":"ghostlight.gestalt_persona_state.v1","id":"yard-workers","name":"Yard workers","version":0,"home_location_id":"yard","shared_capabilities":["maintain machinery"],"shared_knowledge":["yard routines"],"resources":["tool shed"],"goals":["finish the shift"],"pressures":["the gate is failing"]}], "gestalt_members":[{"schema":"ghostlight.gestalt_member_delta.v1","id":"member:john","gestalt_id":"yard-workers","version":0,"name":"John the smith","capability_additions":["forge hinges"],"capability_removals":[],"knowledge_additions":[],"knowledge_removals":[],"equipment":["hammer"],"conditions":[],"obligations":[],"relationships":[],"goals":[],"memories":[],"last_location_id":"yard","materialized_actor_id":null}], - "institutions":[],"clocks":[{"id":"shift","label":"Shift ends","progress":0,"threshold":4,"consequence":"night"}], + "institutions":[],"clocks":[{"id":"shift","label":"Shift ends","progress":0,"threshold":4,"consequence":"night","consequence_scope":{"actor_ids":[],"institution_ids":[],"gestalt_ids":["yard-workers"],"location_ids":["yard"],"public_channels":[]}}], "facts":[ {"id":"f","statement":"A witnessed fact","scope":"canon_baseline","evidence_receipt_ids":["fixture"]}, {"id":"local","statement":"The outer gate indicator is dark.","scope":"branch_local","evidence_receipt_ids":[],"discoverable_at_location_ids":["yard"]} @@ -8797,6 +8808,31 @@ mod tests { assert!(error.to_string().contains("one to 460 characters")); } + #[test] + fn fresh_seed_rejects_due_clock_while_runtime_accepts_legacy_checkpoint() { + let mut campaign = crate::resolution::tests::campaign(2, 1); + campaign.clocks.insert( + "due".into(), + WorldClock { + id: "due".into(), + label: "Due".into(), + progress: 1, + threshold: 1, + consequence: "Faction Zero occupies the counting hall.".into(), + consequence_scope: crate::domain::WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["faction-0000".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["center".into()], + public_channels: Vec::new(), + }, + }, + ); + + assert!(validate_campaign_seed(&campaign).is_err()); + validate_campaign_runtime(&campaign).unwrap(); + } + #[test] fn campaign_seed_rejects_invalid_channels_and_knowledge_disguised_as_channels() { let mut campaign = crate::resolution::tests::campaign(2, 1); diff --git a/crates/ghostlight-dungeon/src/consumer.rs b/crates/ghostlight-dungeon/src/consumer.rs index f150fc4..aca709e 100644 --- a/crates/ghostlight-dungeon/src/consumer.rs +++ b/crates/ghostlight-dungeon/src/consumer.rs @@ -395,6 +395,7 @@ pub fn validate_seed_admission( } profile.simulation_eligible = false; } + crate::compiler::validate_campaign_seed(&campaign)?; let primary = &campaign.player_actor_id; if !campaign.actors.contains_key(primary) && !campaign.institutions.contains_key(primary) diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 1a709b8..36b75e4 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -769,6 +769,39 @@ pub struct InstitutionState { pub posture: String, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(default)] +#[schemars(inline)] +pub struct WorldEventScope { + pub actor_ids: Vec, + pub institution_ids: Vec, + pub gestalt_ids: Vec, + pub location_ids: Vec, + pub public_channels: Vec, +} + +impl Default for WorldEventScope { + fn default() -> Self { + Self { + actor_ids: Vec::new(), + institution_ids: Vec::new(), + gestalt_ids: Vec::new(), + location_ids: Vec::new(), + public_channels: Vec::new(), + } + } +} + +impl WorldEventScope { + pub fn is_unbound(&self) -> bool { + self.actor_ids.is_empty() + && self.institution_ids.is_empty() + && self.gestalt_ids.is_empty() + && self.location_ids.is_empty() + && self.public_channels.is_empty() + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct WorldClock { pub id: String, @@ -777,6 +810,11 @@ pub struct WorldClock { #[schemars(range(min = 1))] pub threshold: u8, pub consequence: String, + /// Exact subjects, places, and information routes affected when this clock + /// first reaches its threshold. Legacy snapshots may be unbound until an + /// admitted reconciliation pass supplies this scope. + #[serde(default)] + pub consequence_scope: WorldEventScope, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -839,6 +877,27 @@ pub fn committed_news_headline(summary: &str) -> String { headline } +pub fn append_event_with_publications(campaign: &mut Campaign, event: Event) { + for channel in &event.public_channels { + campaign.news.push(NewsIssue { + id: event_publication_id(&event.id, channel), + at: event.at, + channel: channel.clone(), + headline: committed_news_headline(&event.summary), + event_ids: vec![event.id.clone()], + reliability: "committed public channel".into(), + }); + } + campaign.events.push(event); +} + +pub fn event_publication_id(event_id: &str, channel: &str) -> String { + use sha2::{Digest, Sha256}; + + let channel_id = format!("{:x}", Sha256::digest(channel.as_bytes()))[..12].to_owned(); + format!("news:{event_id}:{channel_id}") +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] pub struct StrategicTickPlan { /// Canonical admitted strategic choices. The action arrays below are @@ -1165,6 +1224,11 @@ pub enum WorldCommand { proposal: WorldActionProposal, assessment: ActionAssessment, }, + BindClockConsequences { + expected_revision: u64, + admission: crate::clock::ClockConsequenceBindingAdmission, + model_stage_receipts: Vec, + }, } fn default_true() -> bool { diff --git a/crates/ghostlight-dungeon/src/elaboration.rs b/crates/ghostlight-dungeon/src/elaboration.rs index 65d2723..6ef19a7 100644 --- a/crates/ghostlight-dungeon/src/elaboration.rs +++ b/crates/ghostlight-dungeon/src/elaboration.rs @@ -2945,6 +2945,7 @@ mod tests { }; let context = crate::agent::ModelAgentToolContext { source_receipt_ids: Vec::new(), + current_model_receipt: None, }; let rejected = crate::agent::ModelAgentTool::invoke(&mut tool, wrong, &context).await; diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 1c51ace..e04015f 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1140,6 +1140,7 @@ fn execute( evidence_receipts, preview.canon_candidates, model_stage_receipts, + None, Some((transition, mutation_receipt)), ) } @@ -1417,20 +1418,15 @@ fn execute( campaign.resolution_cover = Some(wave.cover.clone()); } campaign.strategic_tick_count = campaign.strategic_tick_count.saturating_add(1); - for event in &tick_events { - for channel in &event.public_channels { - campaign.news.push(crate::domain::NewsIssue { - id: format!("news:{}:{}", event.id, stable_channel_id(channel)), - at: campaign.world_time, - channel: channel.clone(), - headline: crate::domain::committed_news_headline(&event.summary), - event_ids: vec![event.id.clone()], - reliability: "committed public channel".into(), - }); - } + let event_ids = mutation + .as_ref() + .into_iter() + .flat_map(|(_, receipt)| receipt.derived_event_ids.iter().cloned()) + .chain(tick_events.iter().map(|event| event.id.clone())) + .collect(); + for event in tick_events { + crate::domain::append_event_with_publications(&mut campaign, event); } - let event_ids = tick_events.iter().map(|event| event.id.clone()).collect(); - campaign.events.extend(tick_events); if source == TickSource::PlayerWait { campaign.last_player_activity = Utc::now(); campaign.away_ticks_processed = 0; @@ -1494,6 +1490,7 @@ fn execute( evidence_receipts, canon_candidates, model_stage_receipts, + None, Some((transition, mutation_receipt)), ) } @@ -1544,6 +1541,7 @@ fn execute( evidence_receipts, canon_candidates, model_stage_receipts, + None, Some((transition, mutation_receipt)), ) } @@ -2009,6 +2007,36 @@ fn execute( commit(store, row, campaign, "resolve_npc_action", None) } } + WorldCommand::BindClockConsequences { + expected_revision, + admission, + model_stage_receipts, + } => { + require_revision(&campaign, expected_revision)?; + crate::clock::validate_binding_receipts(&campaign, &admission, &model_stage_receipts) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let news_count_before = campaign.news.len(); + let next_wave_index = campaign.strategic_tick_count.saturating_add(1) as usize; + let emitted_event_ids = + crate::clock::apply_clock_consequence_bindings(&mut campaign, &admission.bindings) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + commit_with_records( + store, + row, + campaign, + "bind_clock_consequences", + Vec::new(), + Vec::new(), + model_stage_receipts, + Some(ClockBindingCommitData { + admission, + emitted_event_ids, + news_count_before, + next_wave_index, + }), + None, + ) + } WorldCommand::CreateCampaign { .. } => unreachable!(), } } @@ -4081,10 +4109,6 @@ fn validate_bounded_coop_effect( Ok(()) } -fn stable_channel_id(channel: &str) -> String { - format!("{:x}", Sha256::digest(channel.as_bytes()))[..12].to_string() -} - fn require_active_member<'a>( membership: &'a CampaignMembership, member_id: &str, @@ -4324,6 +4348,13 @@ fn commit_governed_cell_budget( Ok(CommandResult::ResolutionUpdated { campaign, receipt }) } +struct ClockBindingCommitData { + admission: crate::clock::ClockConsequenceBindingAdmission, + emitted_event_ids: Vec, + news_count_before: usize, + next_wave_index: usize, +} + fn commit_with_records( store: &CampaignStore, row: cultcache_legacy::CultCacheEnvelope, @@ -4332,6 +4363,7 @@ fn commit_with_records( evidence: Vec, candidates: Vec, model_receipts: Vec, + clock_binding: Option, mutation: Option<( crate::legacy_transition::LoweredLegacyTransition, crate::transition::WorldMutationReceipt, @@ -4348,18 +4380,71 @@ fn commit_with_records( "mutation receipt does not bind the recorded world commit".into(), )); } + let committed_at = mutation + .as_ref() + .map(|(_, receipt)| receipt.committed_at) + .unwrap_or_else(Utc::now); 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 - .as_ref() - .map(|(_, receipt)| receipt.committed_at) - .unwrap_or_else(Utc::now), + committed_at, roll: None, }; + let clock_binding_receipt = if let Some(binding) = clock_binding { + let emitted_events = binding.emitted_event_ids.iter().collect::>(); + let emitted_news_ids = campaign + .news + .iter() + .skip(binding.news_count_before) + .filter(|news| news.event_ids.iter().any(|id| emitted_events.contains(id))) + .map(|news| news.id.clone()) + .collect::>(); + let expected_news_ids = campaign + .events + .iter() + .filter(|event| emitted_events.contains(&event.id)) + .flat_map(|event| { + event + .public_channels + .iter() + .map(|channel| crate::domain::event_publication_id(&event.id, channel)) + }) + .collect::>(); + if emitted_news_ids.iter().collect::>() + != expected_news_ids.iter().collect::>() + { + return Err(KernelError::Invalid( + "clock consequence binding did not publish the exact admitted event channels" + .into(), + )); + } + Some(crate::clock::ClockConsequenceBindingReceipt { + schema: "ghostlight.clock_consequence_binding_receipt.v1".into(), + campaign_id: campaign.id, + previous_revision, + revision: campaign.revision, + snapshot_binding: binding.admission.snapshot_binding, + binding_batch_digest: binding.admission.binding_batch_digest, + bindings: binding.admission.bindings, + model_receipt_ids: model_receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>() + .into_iter() + .collect(), + accepted_model_receipt_id: binding.admission.accepted_model_receipt_id, + emitted_event_ids: binding.emitted_event_ids, + emitted_news_ids, + news_count_before: binding.news_count_before, + next_wave_index: binding.next_wave_index, + committed_at, + }) + } else { + None + }; store .append_world_commit( &row, @@ -4369,6 +4454,7 @@ fn commit_with_records( &evidence, &candidates, &model_receipts, + clock_binding_receipt.as_ref(), mutation .as_ref() .map(|(transition, receipt)| (&transition.authority, &transition.batch, receipt)), @@ -6935,6 +7021,143 @@ pub(crate) mod tests { assert_eq!(events[0].gestalt_ids, vec!["refugees-east"]); } + #[tokio::test] + async fn clock_binding_commit_is_payload_bound_atomic_and_preserves_player() { + let dir = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); + let mut seed = campaign(); + seed.revision = 14; + seed.institutions.insert( + "court".into(), + InstitutionState { + id: "court".into(), + name: "Court".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "counting knives".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut seed); + seed.agency_profiles + .get_mut("court") + .unwrap() + .information_channels + .extend(["court broadsheet".into(), "palace wire".into()]); + seed.clocks.insert( + "coup".into(), + WorldClock { + id: "coup".into(), + label: "Coup".into(), + progress: 3, + threshold: 3, + consequence: "The palace guard arrests the regent at breakfast.".into(), + consequence_scope: WorldEventScope::default(), + }, + ); + seed.clocks.insert( + "blackout".into(), + WorldClock { + id: "blackout".into(), + label: "Blackout".into(), + progress: 2, + threshold: 2, + consequence: "The archive lamps go dark and the sealed rolls vanish.".into(), + consequence_scope: WorldEventScope::default(), + }, + ); + store + .create_unadmitted_fixture_campaign(&seed, &[], &[]) + .unwrap(); + let player_before = rmp_serde::to_vec_named(&seed.actors["player"]).unwrap(); + let bindings = vec![ + crate::clock::ClockConsequenceBinding { + clock_id: "blackout".into(), + scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["court".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: Vec::new(), + }, + }, + crate::clock::ClockConsequenceBinding { + clock_id: "coup".into(), + scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["court".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: vec!["court broadsheet".into(), "palace wire".into()], + }, + }, + ]; + let snapshot_binding = crate::clock::clock_consequence_binding_snapshot(&seed).unwrap(); + let binding_batch_digest = + crate::clock::clock_consequence_binding_batch_digest(&seed, &bindings).unwrap(); + let mut accepted_receipt = crate::model::ModelStageReceipt { + schema: "ghostlight.persona_stage_receipt.v1".into(), + receipt_hash: String::new(), + provider: "fixture".into(), + model: "fixture-terra".into(), + stage: crate::clock::CLOCK_CONSEQUENCE_BINDING_STAGE.into(), + snapshot_binding: String::new(), + request_hash: test_action_digest("clock-binding-request"), + output_hash: test_action_digest("clock-binding-output"), + source_receipt_ids: Vec::new(), + latency_ms: 1, + validation_result: "valid".into(), + local_validation_error: None, + input_chars: 1, + output_chars: 1, + provider_attempts: Vec::new(), + }; + accepted_receipt.rebind_snapshot(crate::clock::clock_consequence_admission_binding( + &snapshot_binding, + &binding_batch_digest, + )); + let admission = crate::clock::ClockConsequenceBindingAdmission { + schema: "ghostlight.clock_consequence_binding_admission.v1".into(), + campaign_id: seed.id, + expected_revision: seed.revision, + snapshot_binding, + binding_batch_digest, + bindings: bindings.clone(), + accepted_model_receipt_id: accepted_receipt.storage_key().to_owned(), + }; + let kernel = WorldKernel::start(store.clone()); + let CommandResult::Committed { campaign, .. } = kernel + .command(WorldCommand::BindClockConsequences { + expected_revision: seed.revision, + admission, + model_stage_receipts: vec![accepted_receipt], + }) + .await + .unwrap() + else { + panic!("clock binding did not commit") + }; + + assert_eq!( + rmp_serde::to_vec_named(&campaign.actors["player"]).unwrap(), + player_before + ); + let (_, receipt) = store + .load::( + "clock_consequence_binding_receipt.v1", + &format!("{}-{}", campaign.id, campaign.revision), + ) + .unwrap() + .unwrap(); + assert_eq!(receipt.bindings, bindings); + assert_eq!( + receipt.emitted_event_ids, + ["clock-consequence:blackout", "clock-consequence:coup"] + ); + assert_eq!(receipt.emitted_news_ids.len(), 2); + assert_eq!(receipt.news_count_before, 0); + assert_eq!(receipt.next_wave_index, 1); + } + #[tokio::test] async fn stale_command_cannot_mutate_campaign() { let dir = tempfile::tempdir().unwrap(); @@ -7235,6 +7458,16 @@ pub(crate) mod tests { let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); let kernel = WorldKernel::start(store.clone()); let mut seed = campaign(); + seed.institutions.insert( + "clinic".into(), + InstitutionState { + id: "clinic".into(), + name: "Clinic".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "repairing the regulator".into(), + }, + ); seed.clocks.insert( "clinic-failure".into(), WorldClock { @@ -7243,6 +7476,13 @@ pub(crate) mod tests { progress: 3, threshold: 4, consequence: "The regulator fails.".into(), + consequence_scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["clinic".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: Vec::new(), + }, }, ); kernel @@ -7405,6 +7645,85 @@ pub(crate) mod tests { assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1); } + #[tokio::test] + async fn strategic_tick_receipt_includes_derived_clock_consequence() { + let dir = tempfile::tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap(); + let kernel = WorldKernel::start(store.clone()); + let mut seed = campaign(); + seed.institutions.insert( + "court".into(), + InstitutionState { + id: "court".into(), + name: "Court".into(), + resources: Vec::new(), + goals: Vec::new(), + posture: "waiting for the bell".into(), + }, + ); + crate::resolution::ensure_agency_profiles(&mut seed); + seed.agency_profiles + .get_mut("court") + .unwrap() + .information_channels + .insert("court broadsheet".into()); + seed.clocks.insert( + "coup".into(), + WorldClock { + id: "coup".into(), + label: "Coup".into(), + progress: 0, + threshold: 1, + consequence: "The palace guard arrests the regent at breakfast.".into(), + consequence_scope: WorldEventScope { + actor_ids: Vec::new(), + institution_ids: vec!["court".into()], + gestalt_ids: Vec::new(), + location_ids: vec!["room".into()], + public_channels: vec!["court broadsheet".into()], + }, + }, + ); + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed, + evidence_receipts: Vec::new(), + model_stage_receipts: Vec::new(), + }) + .await + .unwrap(); + let CommandResult::Committed { campaign, .. } = kernel + .command(WorldCommand::AdvanceStrategicTick { + expected_revision: 0, + source: TickSource::Scheduler, + plan: None, + model_receipt_hash: None, + resolution_wave: None, + }) + .await + .unwrap() + else { + panic!("expected strategic tick commit") + }; + let ticks = store + .load_all::("strategic_tick.v1") + .unwrap(); + + assert_eq!(ticks[0].event_ids, ["clock-consequence:coup"]); + assert!( + campaign + .events + .iter() + .any(|event| event.id == "clock-consequence:coup") + ); + assert!( + campaign + .news + .iter() + .any(|news| news.event_ids == ["clock-consequence:coup"]) + ); + } + #[tokio::test] async fn strategic_plan_moves_remote_actors_but_cannot_puppet_the_player() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index 164aa8b..1ce24be 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -998,9 +998,11 @@ pub fn apply_lowered_transition( now: DateTime, ) -> Result { let snapshot = component_snapshot(campaign)?; - let application = + let mut application = apply_component_world_batch(&snapshot, &transition.authority, &transition.batch, now)?; project_mutated_components(campaign, &application.state, &transition.batch)?; + application.receipt.derived_event_ids = + crate::clock::materialize_due_clock_consequences(campaign)?; Ok(application.receipt) } @@ -3378,6 +3380,7 @@ mod tests { progress: 2, threshold: 6, consequence: "The doors lock.".into(), + consequence_scope: Default::default(), }, )]), facts: BTreeMap::from([( diff --git a/crates/ghostlight-dungeon/src/lib.rs b/crates/ghostlight-dungeon/src/lib.rs index f708414..372373d 100644 --- a/crates/ghostlight-dungeon/src/lib.rs +++ b/crates/ghostlight-dungeon/src/lib.rs @@ -3,6 +3,7 @@ pub mod agency_corpus; pub mod agent; pub mod assessor; +pub mod clock; pub mod compiler; pub mod consumer; pub mod d20; diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index cc40e1d..208c7f1 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -5503,7 +5503,8 @@ fn player_http_command_allowed(command: &WorldCommand, player_actor_id: &str) -> | WorldCommand::ResolveNpcAction { .. } | WorldCommand::SetProviderParallelism { .. } | WorldCommand::ReplaceResolutionPins { .. } - | WorldCommand::FissionGestalt { .. } => false, + | WorldCommand::FissionGestalt { .. } + | WorldCommand::BindClockConsequences { .. } => false, } } diff --git a/crates/ghostlight-dungeon/src/persistence.rs b/crates/ghostlight-dungeon/src/persistence.rs index 4b652cc..2422ad8 100644 --- a/crates/ghostlight-dungeon/src/persistence.rs +++ b/crates/ghostlight-dungeon/src/persistence.rs @@ -1,3 +1,4 @@ +use crate::clock::ClockConsequenceBindingReceipt; use crate::consumer::{ ExternalProposalReceipt, ExternalSnapshotReceipt, ExternalSubjectAuthority, ExternalWorldProposal, WorldSeedAdmission, WorldSeedAdmissionReceipt, @@ -843,6 +844,7 @@ impl CampaignStore { evidence: &[VaultEvidenceReceipt], candidates: &[crate::domain::CanonCandidate], model_receipts: &[crate::model::ModelStageReceipt], + clock_binding_receipt: Option<&ClockConsequenceBindingReceipt>, mutation: Option<( &MutationAuthorityEnvelope, &WorldMutationBatch, @@ -927,6 +929,40 @@ impl CampaignStore { )?); } } + if let Some(binding) = clock_binding_receipt { + let supplied_model_receipt_ids = model_receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>(); + if receipt.command_kind != "bind_clock_consequences" + || binding.campaign_id != receipt.campaign_id + || binding.previous_revision != receipt.previous_revision + || binding.revision != receipt.revision + || binding.committed_at != receipt.committed_at + || !supplied_model_receipt_ids.contains(&binding.accepted_model_receipt_id) + || binding.model_receipt_ids.len() != supplied_model_receipt_ids.len() + || binding + .model_receipt_ids + .iter() + .cloned() + .collect::>() + != supplied_model_receipt_ids + { + return Err(anyhow!( + "clock consequence binding receipt contradicts its world commit bundle" + )); + } + self.append_idempotent_companion( + &mut expected_rows, + &mut rows, + envelope( + "clock_consequence_binding_receipt.v1", + "ghostlight.clock_consequence_binding_receipt.v1", + &format!("{}-{}", binding.campaign_id, binding.revision), + binding, + )?, + )?; + } if let Some(mutation) = mutation { append_mutation_proof(&mut rows, receipt, mutation)?; } @@ -1339,6 +1375,7 @@ mod tests { &[], std::slice::from_ref(&repeated_receipt), None, + None, ) .unwrap(); @@ -1396,6 +1433,7 @@ mod tests { &[], std::slice::from_ref(&collision), None, + None, ) .unwrap_err(); diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 881898f..b36b11b 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -4128,6 +4128,7 @@ mod tests { }; let context = ModelAgentToolContext { source_receipt_ids: vec!["persona:one".into()], + current_model_receipt: None, }; let initial_schema = workbench.action_schema().unwrap(); let initial_schema_text = serde_json::to_string(&initial_schema).unwrap(); @@ -4312,6 +4313,7 @@ mod tests { }, &ModelAgentToolContext { source_receipt_ids: vec!["persona:one".into()], + current_model_receipt: None, }, ) .await; diff --git a/crates/ghostlight-dungeon/src/scheduler.rs b/crates/ghostlight-dungeon/src/scheduler.rs index a099af8..3741301 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -1864,6 +1864,7 @@ mod tests { progress: 3, threshold: 4, consequence: "the camp is cut off by the storm".into(), + consequence_scope: Default::default(), }; assert_eq!( project_world_clock_pressure(&clock), diff --git a/crates/ghostlight-dungeon/src/surface.rs b/crates/ghostlight-dungeon/src/surface.rs index 56bbb8c..c4cccf5 100644 --- a/crates/ghostlight-dungeon/src/surface.rs +++ b/crates/ghostlight-dungeon/src/surface.rs @@ -609,6 +609,7 @@ mod tests { progress: 3, threshold: 4, consequence: "SECRET_ARREST_PLAN".into(), + consequence_scope: Default::default(), }, ); campaign.news.push(crate::domain::NewsIssue { diff --git a/crates/ghostlight-dungeon/src/transition.rs b/crates/ghostlight-dungeon/src/transition.rs index 028945e..7c19393 100644 --- a/crates/ghostlight-dungeon/src/transition.rs +++ b/crates/ghostlight-dungeon/src/transition.rs @@ -642,6 +642,8 @@ pub struct WorldMutationReceipt { pub world_revision: u64, pub mutation_digests: Vec, pub component_versions: Vec, + #[serde(default)] + pub derived_event_ids: Vec, pub committed_at: DateTime, } @@ -985,6 +987,7 @@ pub fn apply_component_world_batch( world_revision: previous_revision.saturating_add(1), mutation_digests, component_versions, + derived_event_ids: Vec::new(), committed_at: now, }, }) diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 976c5d7..a2ccc66 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -114,6 +114,13 @@ Such actions persist as attributed proposals in the same strategic-wave commit and return for consumer admission, not direct mutation. Compiler and consumer producers share one `WorldSeed` admission transaction. +Clock consequences cannot use an externally controlled subject as their +affected or responsible scope. They may still reach that subject through an +exact place or public channel, but only the consumer may decide what happens +inside its sovereign state. Temporary materialized-member actor projections +are also ineligible clock anchors; consequences bind stable simulated people, +institutions, or populations. + The Delvehold adapter remains Delvehold-owned. It lowers committed Greathold projections and realized effects into generic Ghostlight operations, then raises attributed Ghostlight intents, news, projections, and receipts into Delvehold's @@ -230,6 +237,20 @@ Interpreter draft repair remains workbench-local. Actor and cell membranes share one causal-source derivation primitive for Projector to Persona to Interpreter, while canonical evidence remains a separate namespace. +World clocks own declared consequence text and exact observable scope. New +seeds must supply that scope and begin below threshold. A persisted legacy +clock without scope enters one balanced-model binding agent over a frozen +campaign revision. The agent proposes the smallest causally sufficient stable, +simulation-eligible scope; its accepted action is rebound to the exact batch +digest. `WorldKernel` alone admits the proposal. The common mutation application +path detects threshold crossing and derives one stable +`clock-consequence:` event, then the shared event publisher derives +the exact rows for every admitted public channel. The event IDs enter mutation +and strategic-tick receipts. Binding scope, exact model receipt ancestry, +emitted event and news IDs, and the next newspaper boundary commit atomically +with the campaign in CultCache. Runner proposal and completion checkpoints are +recoverable projections; neither owns the event nor the newspaper slice. + The effect verifier receives the same exact typed permission slice used by the Interpreter. That slice is its sole map of canonical locations, reachable actor destinations, population destinations, and target IDs. A pump, desk, queue, or