From 3bfd2cdb291dc0c2820d4e0c4224df323b65a5c4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 26 Aug 2026 13:04:09 +0200 Subject: [PATCH] Bind strategic agency before inference --- crates/ghostlight-dungeon/src/kernel.rs | 55 +++++++++- crates/ghostlight-dungeon/src/outcome.rs | 26 +++-- crates/ghostlight-dungeon/src/persona.rs | 49 +++------ crates/ghostlight-dungeon/src/scheduler.rs | 114 ++++++++++++++++++--- crates/ghostlight-dungeon/src/surface.rs | 35 ++++++- 5 files changed, 213 insertions(+), 66 deletions(-) diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index a9bef91..84d65c4 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -2217,6 +2217,15 @@ fn apply_strategic_tick_plan( crate::outcome::validate_plan_activity_outcomes(campaign, &plan) .map_err(|error| KernelError::Invalid(error.to_string()))?; let canonical_composition = !plan.selected_actions.is_empty(); + let activity_means = plan + .selected_actions + .iter() + .map(|action| { + crate::resolution::cell_action_digest(action) + .map(|digest| (digest, action.intended_effect.clone())) + .map_err(|error| KernelError::Invalid(error.to_string())) + }) + .collect::, _>>()?; let prospective_actor_locations = plan .actor_moves .iter() @@ -2518,7 +2527,14 @@ fn apply_strategic_tick_plan( ), at, kind: "gestalt_activity".into(), - summary: strategic_activity_summary(&gestalt.name, &action.activity, &target_names), + summary: strategic_activity_summary( + &gestalt.name, + &action.activity, + &target_names, + activity_means + .get(&action.action_digest) + .map(String::as_str), + ), actor_ids, institution_ids, gestalt_ids, @@ -2674,7 +2690,14 @@ fn apply_strategic_tick_plan( ), at, kind: "actor_activity".into(), - summary: strategic_activity_summary(&actor.name, &action.activity, &target_names), + summary: strategic_activity_summary( + &actor.name, + &action.activity, + &target_names, + activity_means + .get(&action.action_digest) + .map(String::as_str), + ), actor_ids, institution_ids, gestalt_ids, @@ -2784,7 +2807,14 @@ fn apply_strategic_tick_plan( ), at, kind: "gestalt_member_activity".into(), - summary: strategic_activity_summary(&member.name, &action.activity, &target_names), + summary: strategic_activity_summary( + &member.name, + &action.activity, + &target_names, + activity_means + .get(&action.action_digest) + .map(String::as_str), + ), actor_ids, institution_ids, gestalt_ids, @@ -2858,6 +2888,12 @@ fn apply_strategic_tick_plan( .ok_or_else(|| { KernelError::Invalid("strategic outcome lost its activity context".into()) })?; + if matches!( + &outcome.effect, + crate::domain::StrategicOutcomeEffect::NoMaterialChange { .. } + ) { + continue; + } let mut subject_ids = BTreeSet::from([source_subject_id.clone()]); collect_outcome_subject_ids(&outcome.effect, &mut subject_ids); let mut actor_ids = Vec::new(); @@ -2973,7 +3009,20 @@ fn strategic_activity_summary( source_name: &str, activity: &StrategicActivityKind, target_names: &[String], + admitted_means: Option<&str>, ) -> String { + if let Some(means) = admitted_means + .map(str::trim) + .filter(|means| !means.is_empty()) + { + let means = means.trim_end_matches(['.', '!', '?']); + let mut characters = means.chars(); + let lowered = characters + .next() + .map(|first| first.to_lowercase().chain(characters).collect::()) + .unwrap_or_default(); + return format!("{source_name} attempts to {lowered}."); + } let targets = target_names.join(", "); match (activity, target_names.is_empty()) { (StrategicActivityKind::Prepare, true) => { diff --git a/crates/ghostlight-dungeon/src/outcome.rs b/crates/ghostlight-dungeon/src/outcome.rs index 65a0229..708bf79 100644 --- a/crates/ghostlight-dungeon/src/outcome.rs +++ b/crates/ghostlight-dungeon/src/outcome.rs @@ -881,19 +881,23 @@ fn resolved_outcome_summary( to_subject_ids, fact_id, .. - } => format!( - "{source_name} communicates {} to {}.", - campaign + } => { + let statement = campaign .facts .get(fact_id) .ok_or_else(|| anyhow!("knowledge outcome fact vanished"))? - .statement, - to_subject_ids - .iter() - .map(|subject_id| subject_name(campaign, subject_id)) - .collect::>>()? - .join(", ") - ), + .statement + .trim() + .trim_end_matches(['.', '!', '?']); + format!( + "{source_name} shares this with {}: {statement}.", + to_subject_ids + .iter() + .map(|subject_id| subject_name(campaign, subject_id)) + .collect::>>()? + .join(", ") + ) + } }; let summary = summary.chars().take(240).collect::(); if !bounded_text(&summary, 240) { @@ -2990,7 +2994,7 @@ mod tests { action_digest: cell_action_digest(&action).unwrap(), source_subject_id: "dockers".into(), band: StrategicOutcomeBand::Success, - summary: "Dockers communicates the route fact to Reed.".into(), + summary: "Dockers shares this with Reed: the western causeway remains open.".into(), supporting_state_references: vec!["fact:fact:safe-route".into()], effect: StrategicOutcomeEffect::KnowledgeCommunicated { from_subject_id: "dockers".into(), diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index a793839..6b32adb 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -154,6 +154,10 @@ pub struct PermittedCellSlice { pub perceived_events: Vec, pub world_clock_pressure: Vec, pub detail_focus_subject_id: Option, + /// Exact subjects that own this cell's bounded decision slots for the + /// current strategic wave. Resolution chooses these before inference; + /// Projector output may describe them but may not decide who participates. + pub decision_owner_ids: BTreeSet, pub max_actions: usize, pub source_receipt_ids: Vec, } @@ -908,25 +912,21 @@ fn constrain_cell_projection_schema( .and_then(serde_json::Value::as_object_mut) .ok_or_else(|| anyhow!("cell projection schema has no segment array"))?; let required_count = required_projection_subject_ids(slice).len(); - segments.insert("minItems".into(), required_count.max(1).into()); - segments.insert( - "maxItems".into(), - slice.max_actions.max(required_count).max(1).into(), - ); + if required_count == 0 || required_count > slice.max_actions { + return Err(anyhow!( + "resolution selected an invalid number of cell decision owners" + )); + } + segments.insert("minItems".into(), required_count.into()); + segments.insert("maxItems".into(), required_count.into()); let segment = schema .pointer_mut("/$defs/CellPerspectiveSegment/properties") .and_then(serde_json::Value::as_object_mut) .ok_or_else(|| anyhow!("cell projection schema has no segment properties"))?; let mut subject_ids = slice - .constituents + .decision_owner_ids .iter() - .map(|subject| subject.subject_id.as_str()) - .chain( - slice - .member_exceptions - .iter() - .map(|member| member.subject_id.as_str()), - ) + .map(String::as_str) .collect::>(); if let Some(focus) = slice.detail_focus_subject_id.as_deref() && let Some(index) = subject_ids @@ -1123,27 +1123,7 @@ fn agency_footing( } fn required_projection_subject_ids(slice: &PermittedCellSlice) -> BTreeSet { - slice - .detail_focus_subject_id - .iter() - .filter(|focus| { - slice - .constituents - .iter() - .any(|subject| &subject.subject_id == *focus) - || slice - .member_exceptions - .iter() - .any(|member| &member.subject_id == *focus) - }) - .cloned() - .chain( - slice - .member_exceptions - .iter() - .map(|member| member.subject_id.clone()), - ) - .collect() + slice.decision_owner_ids.clone() } fn allowed_constituent_effect_types(subject: &CellConstituentSlice) -> Vec<&'static str> { @@ -3342,6 +3322,7 @@ mod tests { }], world_clock_pressure: vec!["vote 5/6".into()], detail_focus_subject_id: Some("faction-06".into()), + decision_owner_ids: BTreeSet::from(["faction-06".into()]), max_actions: 1, source_receipt_ids: vec![], } diff --git a/crates/ghostlight-dungeon/src/scheduler.rs b/crates/ghostlight-dungeon/src/scheduler.rs index 8d18966..ffb9e58 100644 --- a/crates/ghostlight-dungeon/src/scheduler.rs +++ b/crates/ghostlight-dungeon/src/scheduler.rs @@ -487,7 +487,12 @@ fn resolution_demand_context(campaign: &Campaign) -> (Vec, serde_json::V } fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result { - let mut member_exceptions = member_exceptions(campaign, cell)?; + let member_candidates = member_exceptions(campaign, cell)?; + let decision_owner_ids = select_cell_decision_owners(campaign, cell, &member_candidates)?; + let mut member_exceptions = member_candidates + .into_iter() + .filter(|member| decision_owner_ids.contains(&member.subject_id)) + .collect::>(); let selected_member_ids = member_exceptions .iter() .map(|member| member.subject_id.clone()) @@ -540,11 +545,76 @@ fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result Result> { + let quota = cell_action_limit(cell); + if quota == 0 || cell.subject_ids.is_empty() { + return Err(anyhow!("simulation cell has no bounded decision capacity")); + } + if let Some(focus) = cell.detail_focus_subject_id.as_deref() + && !cell.subject_ids.contains(focus) + { + return Err(anyhow!("simulation cell detail focus is outside the cell")); + } + + let canonical = cell.subject_ids.iter().cloned().collect::>(); + let mut selected = BTreeSet::new(); + if let Some(focus) = cell.detail_focus_subject_id.as_ref() { + selected.insert(focus.clone()); + } + let available = canonical + .iter() + .filter(|subject_id| !selected.contains(*subject_id)) + .cloned() + .collect::>(); + let remaining = quota.saturating_sub(selected.len()).min(available.len()); + if remaining == available.len() { + selected.extend(available); + } else if remaining > 0 { + let start = + (campaign.strategic_tick_count as usize).saturating_mul(remaining) % available.len(); + for offset in 0..remaining { + selected.insert(available[(start + offset) % available.len()].clone()); + } + } + + let mut owners = BTreeSet::new(); + for subject_id in selected { + if cell.detail_focus_subject_id.as_deref() == Some(subject_id.as_str()) + || !campaign.gestalts.contains_key(&subject_id) + { + owners.insert(subject_id); + continue; + } + let mut alternatives = std::iter::once(subject_id.clone()) + .chain( + member_candidates + .iter() + .filter(|member| member.source_gestalt_id == subject_id) + .map(|member| member.subject_id.clone()), + ) + .collect::>(); + alternatives.dedup(); + let index = campaign.strategic_tick_count as usize % alternatives.len(); + owners.insert(alternatives.swap_remove(index)); + } + if owners.is_empty() || owners.len() > quota { + return Err(anyhow!( + "resolution produced an invalid exact decision-owner set" + )); + } + Ok(owners) +} + fn project_world_clock_pressure(clock: &crate::domain::WorldClock) -> String { if clock.progress >= clock.threshold { format!( @@ -648,9 +718,6 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result Result { - let subject_id = request + let subject_ids = request .output_schema .as_ref() .and_then(|schema| { - schema.pointer( - "/$defs/CellPerspectiveSegment/properties/subject_id/enum/0", - ) + schema + .pointer("/$defs/CellPerspectiveSegment/properties/subject_id/enum") }) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| anyhow!("fixture projector lacks a bound subject"))?; + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + }) + .filter(|values| !values.is_empty()) + .ok_or_else(|| anyhow!("fixture projector lacks bound subjects"))?; Ok(serde_json::json!({ - "segments":[{ + "segments":subject_ids.into_iter().map(|subject_id| serde_json::json!({ "subject_id":subject_id, - "narrative":"The horizon tightens around our own unresolved choice." - }] + "narrative":"The horizon tightens around this subject's own unresolved choice." + })).collect::>() }) .to_string()) } @@ -1327,7 +1399,7 @@ mod tests { } #[test] - fn cell_slice_projects_only_actionable_salient_member_exceptions() { + fn cell_slice_rotates_exact_member_decision_owners_without_hiding_the_cell() { use crate::domain::*; let mut campaign = crate::resolution::tests::campaign(0, 1); let gestalt = |id: &str, name: &str| GestaltPersonaState { @@ -1384,6 +1456,7 @@ mod tests { .gestalt_members .insert(member_id.into(), member(member_id, false)); } + campaign.strategic_tick_count = 1; crate::resolution::ensure_agency_profiles(&mut campaign); campaign.agency_relations.insert( "migration".into(), @@ -1404,8 +1477,11 @@ mod tests { ) .unwrap(); let slice = cell_slice(&campaign, &cover.cells[0]).unwrap(); - assert_eq!(slice.member_exceptions.len(), 2); + assert_eq!(slice.member_exceptions.len(), 1); assert_eq!(slice.member_exceptions[0].member_id, "mira"); + assert!(slice.decision_owner_ids.contains("member:mira")); + assert!(slice.decision_owner_ids.contains("neighbors")); + assert!(!slice.decision_owner_ids.contains("refugees")); assert_eq!( slice.member_exceptions[0] .migration_destinations @@ -1476,6 +1552,12 @@ mod tests { .contains("private dock code") ); + let mut focused_cell = cover.cells[0].clone(); + focused_cell.detail_focus_subject_id = Some("refugees".into()); + let focused = cell_slice(&campaign, &focused_cell).unwrap(); + assert!(focused.decision_owner_ids.contains("refugees")); + assert!(!focused.decision_owner_ids.contains("member:mira")); + campaign.agency_relations.clear(); let local_only_cover = crate::resolution::plan_cover( &campaign, diff --git a/crates/ghostlight-dungeon/src/surface.rs b/crates/ghostlight-dungeon/src/surface.rs index ef41ce2..b581933 100644 --- a/crates/ghostlight-dungeon/src/surface.rs +++ b/crates/ghostlight-dungeon/src/surface.rs @@ -136,7 +136,11 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V .filter(|event| { event.kind != "reaction_wave" && event.kind != "group_travel" - && !event.actor_ids.iter().any(|actor_id| actor_id == viewer_actor_id) + && (autonomous_strategic_event(event) + || !event + .actor_ids + .iter() + .any(|actor_id| actor_id == viewer_actor_id)) }) .filter(|event| { player_profile.is_some_and(|profile| { @@ -310,6 +314,21 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V }) } +fn autonomous_strategic_event(event: &crate::domain::Event) -> bool { + matches!( + event.kind.as_str(), + "institution_action" + | "gestalt_action" + | "gestalt_migration" + | "gestalt_activity" + | "actor_movement" + | "actor_activity" + | "gestalt_member_activity" + | "gestalt_member_migration" + | "strategic_activity_outcome" + ) +} + fn local_draft(name: &str, value_kind: &str) -> Value { json!({"targetProp":"value","pointerId":format!("draft:{name}"),"sourceId":"renderer","schemaId":"gamecult.eve.local_draft.v1","routeKind":"local","bindingName":name,"documentId":"ghostlight.play.drafts","fieldPath":name,"valueKind":value_kind,"accessMode":"local-draft","authority":"renderer-ephemeral"}) } @@ -694,7 +713,7 @@ mod tests { crate::domain::Event { id: "event:own-activity".into(), at: Utc::now(), - kind: "actor_activity".into(), + kind: "player_action".into(), summary: "PLAYER_PRIVATE_ACTIVITY_SUMMARY".into(), actor_ids: vec!["player".into()], institution_ids: vec![], @@ -735,6 +754,17 @@ mod tests { location_ids: vec!["center".into()], public_channels: vec![], }, + crate::domain::Event { + id: "event:targeted".into(), + at: Utc::now(), + kind: "actor_activity".into(), + summary: "The local watch warns the player directly.".into(), + actor_ids: vec!["faction-0000".into(), "player".into()], + institution_ids: vec![], + gestalt_ids: vec![], + location_ids: vec!["center".into()], + public_channels: vec![], + }, ]); let encoded = serde_json::to_string(&player_surface(&campaign)).unwrap(); @@ -743,6 +773,7 @@ mod tests { assert!(!encoded.contains("PLAYER_SPEECH_DUPLICATE")); assert!(!encoded.contains("PLAYER_TRAVEL_DUPLICATE")); assert!(encoded.contains("The local watch reinforces the gate.")); + assert!(encoded.contains("The local watch warns the player directly.")); } #[test]