From b98ca9abc3fc4432874ad5e714de763bdbeb977c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 27 Aug 2026 04:41:47 +0200 Subject: [PATCH] Align internal gestalt coordination authority --- crates/ghostlight-dungeon/src/domain.rs | 4 ++ crates/ghostlight-dungeon/src/kernel.rs | 71 ++++++++++++++++++++- crates/ghostlight-dungeon/src/persona.rs | 23 ++++++- crates/ghostlight-dungeon/src/resolution.rs | 41 +++++++++++- 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 75934da..7fccdbc 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -548,6 +548,10 @@ impl StrategicActivityKind { Self::Prepare | Self::Investigate | Self::Obstruct | Self::Communicate ) } + + pub fn requires_explicit_target_for_gestalt(&self) -> bool { + !self.allows_targetless_local_attempt() && !matches!(self, Self::Coordinate) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index facab06..01f3fe0 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -2872,7 +2872,7 @@ fn apply_strategic_tick_plan( crate::resolution::strategic_activity_targets(campaign, &action.gestalt_id); let unique_targets = action.target_subject_ids.iter().collect::>(); let unique_locations = action.location_ids.iter().collect::>(); - let needs_target = !action.activity.allows_targetless_local_attempt(); + let needs_target = action.activity.requires_explicit_target_for_gestalt(); if action.target_subject_ids.len() > 4 || unique_targets.len() != action.target_subject_ids.len() || action @@ -3473,6 +3473,9 @@ fn strategic_activity_summary( (StrategicActivityKind::Coordinate, false) => { format!("{source_name} attempts to coordinate with {targets}.") } + (StrategicActivityKind::Coordinate, true) => { + format!("{source_name} attempts internal coordination.") + } (StrategicActivityKind::Investigate, true) => { format!("{source_name} begins a local investigation.") } @@ -5749,6 +5752,72 @@ pub(crate) mod tests { ); } + #[test] + fn cohesive_gestalt_internal_coordination_reaches_canonical_and_legacy_commit_paths() { + let action = CellActionProposal { + subject_id: "refugees-east".into(), + intent: "coordinate the households' own water watch".into(), + intended_effect: "coordinate internal water-watch shifts without naming an external party" + .into(), + priority: 80, + state_references: vec![], + public_channels: vec![], + effects: vec![StrategicCellEffect::GestaltActivity { + gestalt_id: "refugees-east".into(), + activity: StrategicActivityKind::Coordinate, + target_subject_ids: vec![], + location_ids: vec!["camp".into()], + }], + }; + let action_digest = crate::resolution::cell_action_digest(&action).unwrap(); + let mut canonical = hierarchical_refugee_campaign(); + let canonical_events = apply_strategic_tick_plan( + &mut canonical, + StrategicTickPlan { + selected_actions: vec![action], + activity_outcomes: vec![StrategicActivityOutcome { + schema: "ghostlight.strategic_activity_outcome.v1".into(), + action_digest, + source_subject_id: "refugees-east".into(), + band: StrategicOutcomeBand::Mixed, + summary: "The households arrange the watch; its efficacy remains unsettled." + .into(), + supporting_state_references: vec![], + effect: StrategicOutcomeEffect::NoMaterialChange { + reason: "No external response or durable result is established.".into(), + }, + }], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(canonical_events.len(), 1); + assert_eq!(canonical_events[0].kind, "gestalt_activity"); + assert!(canonical_events[0].summary.contains("internal water-watch shifts")); + + let mut legacy = hierarchical_refugee_campaign(); + let legacy_events = apply_strategic_tick_plan( + &mut legacy, + resolve_test_activities(StrategicTickPlan { + gestalt_activities: vec![StrategicGestaltActivity { + action_digest: test_action_digest("internal water-watch coordination"), + gestalt_id: "refugees-east".into(), + activity: StrategicActivityKind::Coordinate, + target_subject_ids: vec![], + location_ids: vec!["camp".into()], + public_channels: vec![], + }], + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(legacy_events.len(), 1); + assert_eq!( + legacy_events[0].summary, + "Eastern transit refugees attempts internal coordination." + ); + } + #[test] fn canonical_action_commits_distinct_scopes_of_one_activity_kind() { let mut value = hierarchical_refugee_campaign(); diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 5ab2e89..2de134a 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -2433,7 +2433,7 @@ fn validate_constituent_effect( } => { let unique_targets = target_subject_ids.iter().collect::>(); let unique_locations = location_ids.iter().collect::>(); - let needs_target = !activity.allows_targetless_local_attempt(); + let needs_target = activity.requires_explicit_target_for_gestalt(); if needs_target && target_subject_ids.is_empty() { return Err(anyhow!( "gestalt {} activity {:?} requires one or more exact target IDs; no anonymous or unsupplied target can be encoded. Remove the action unless the Persona explicitly attempted one of {:?}", @@ -4430,6 +4430,14 @@ mod tests { }; validate_constituent_effect(subject, &valid, None).unwrap(); + let internal_coordination = StrategicCellEffect::GestaltActivity { + gestalt_id: "refugees".into(), + activity: crate::domain::StrategicActivityKind::Coordinate, + target_subject_ids: vec![], + location_ids: vec!["forum".into()], + }; + validate_constituent_effect(subject, &internal_coordination, None).unwrap(); + let invented_target = StrategicCellEffect::GestaltActivity { gestalt_id: "refugees".into(), activity: crate::domain::StrategicActivityKind::Coordinate, @@ -4561,6 +4569,19 @@ mod tests { .unwrap(); validate_cell_appraisal(&slice, &appraisal).unwrap(); + let mut targetless_coordination = appraisal.clone(); + let StrategicCellEffect::MemberActivity { + activity, + target_subject_ids, + .. + } = &mut targetless_coordination.actions[0].effects[0] + else { + unreachable!() + }; + *activity = crate::domain::StrategicActivityKind::Coordinate; + target_subject_ids.clear(); + assert!(validate_cell_appraisal(&slice, &targetless_coordination).is_err()); + let mut stolen = appraisal; let StrategicCellEffect::MemberActivity { member_id, .. } = &mut stolen.actions[0].effects[0] diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index 289077d..77ced31 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -2339,7 +2339,7 @@ fn validate_cell_proposal( let allowed_targets = strategic_activity_targets(campaign, gestalt_id); let unique_targets = target_subject_ids.iter().collect::>(); let unique_locations = location_ids.iter().collect::>(); - let needs_target = !activity.allows_targetless_local_attempt(); + let needs_target = activity.requires_explicit_target_for_gestalt(); if gestalt_id != &proposal.subject_id || !campaign.gestalts.contains_key(gestalt_id) || target_subject_ids.len() > 4 @@ -4467,6 +4467,21 @@ pub(crate) mod tests { target_subject_ids.clear(); assert!(select_resolution_wave(&value, &make_wave(local_member_communication)).is_ok()); + let mut targetless_member_coordination = member_activity.clone(); + let StrategicCellEffect::MemberActivity { + activity, + target_subject_ids, + .. + } = &mut targetless_member_coordination.effects[0] + else { + unreachable!() + }; + *activity = StrategicActivityKind::Coordinate; + target_subject_ids.clear(); + assert!( + select_resolution_wave(&value, &make_wave(targetless_member_coordination)).is_err() + ); + let same_member_wave = ResolutionWaveCommit { schema: "ghostlight.resolution_wave_commit.v1".into(), world_revision: value.revision, @@ -4527,6 +4542,30 @@ pub(crate) mod tests { assert_eq!(activity_plan.gestalt_activities.len(), 1); assert!(activity_plan.gestalt_actions.is_empty()); + let internal_coordination = CellActionProposal { + subject_id: "refugees".into(), + intent: "coordinate the refugee households' own water watch".into(), + intended_effect: "attempt internal coordination without inventing an external target" + .into(), + priority: 80, + state_references: vec!["subject:refugees".into()], + public_channels: vec![], + effects: vec![StrategicCellEffect::GestaltActivity { + gestalt_id: "refugees".into(), + activity: StrategicActivityKind::Coordinate, + target_subject_ids: vec![], + location_ids: vec!["center".into()], + }], + }; + let internal_plan = + select_resolution_wave(&value, &make_wave(internal_coordination)) + .unwrap() + .plan; + assert_eq!(internal_plan.gestalt_activities.len(), 1); + assert!(internal_plan.gestalt_activities[0] + .target_subject_ids + .is_empty()); + let lower_priority_pressure = CellActionProposal { subject_id: "refugees".into(), intent: "register the unresolved exclusion".into(),