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 08bdd54..5c6ef87 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -298,12 +298,14 @@ async fn main() -> anyhow::Result<()> { .flat_map(|appraisal| &appraisal.actions) .find(|proposal| { proposal.subject_id == "member:mira-venn" - && matches!( - &proposal.effect, - ghostlight_dungeon::domain::StrategicCellEffect::MemberMigration { - destination_gestalt_id, - } if destination_gestalt_id == "harbor-neighbors" - ) + && proposal.effects.iter().any(|effect| { + matches!( + effect, + ghostlight_dungeon::domain::StrategicCellEffect::MemberMigration { + destination_gestalt_id, + } if destination_gestalt_id == "harbor-neighbors" + ) + }) }) .cloned(); if require_migration && migration_proposal.is_none() { diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs index 39f5b40..3b9f255 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs @@ -125,12 +125,12 @@ async fn main() -> anyhow::Result<()> { priority: 100, state_references, public_channels: vec![], - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: source_id.into(), activity: StrategicActivityKind::Trade, target_subject_ids: vec![target_id.into()], location_ids: vec![location], - }, + }], }; let calls = Arc::new(std::sync::Mutex::new(Vec::new())); let secret = std::env::var_os("GHOSTLIGHT_DEEPSEEK_BLOB") diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index ca28c9e..6fdf304 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -493,7 +493,9 @@ pub struct CellActionProposal { pub priority: i16, pub state_references: Vec, pub public_channels: Vec, - pub effect: StrategicCellEffect, + #[serde(default)] + #[schemars(length(min = 1, max = 4))] + pub effects: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -706,6 +708,11 @@ pub struct NewsIssue { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] pub struct StrategicTickPlan { + /// Canonical admitted strategic choices. The action arrays below are + /// deterministic projections used by the legacy event and transition + /// lowering path; production callers rebuild them from this list. + #[serde(default)] + pub selected_actions: Vec, pub institution_actions: Vec, pub gestalt_actions: Vec, #[serde(default)] diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 296e7c8..9fccafe 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1886,8 +1886,44 @@ fn apply_strategic_tick_plan( campaign: &mut Campaign, plan: crate::domain::StrategicTickPlan, ) -> Result { + let plan = if plan.selected_actions.is_empty() { + plan + } else { + let activity_outcomes = plan.activity_outcomes; + let mut projected = + crate::resolution::project_selected_actions(campaign, plan.selected_actions) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + projected.activity_outcomes = activity_outcomes; + projected + }; 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 prospective_actor_locations = plan + .actor_moves + .iter() + .map(|action| (action.actor_id.clone(), action.destination_id.clone())) + .collect::>(); + let prospective_gestalt_locations = plan + .gestalt_migrations + .iter() + .map(|action| { + ( + action.gestalt_id.clone(), + action.destination_location_id.clone(), + ) + }) + .collect::>(); + let prospective_member_locations = plan + .member_migrations + .iter() + .map(|action| { + ( + action.member_id.clone(), + action.destination_location_id.clone(), + ) + }) + .collect::>(); let lowering_plan = plan.clone(); let activity_outcomes = plan.activity_outcomes.clone(); let mut outcome_event_context = BTreeMap::new(); @@ -1974,9 +2010,12 @@ fn apply_strategic_tick_plan( }); } - let mut seen_gestalts = BTreeSet::new(); + let mut legacy_seen_gestalts = BTreeSet::new(); + let mut seen_gestalt_pressures = BTreeSet::new(); for action in plan.gestalt_actions { - if !seen_gestalts.insert(action.gestalt_id.clone()) { + if !seen_gestalt_pressures.insert(action.gestalt_id.clone()) + || (!canonical_composition && !legacy_seen_gestalts.insert(action.gestalt_id.clone())) + { return Err(KernelError::Invalid( "gestalt acts twice in one strategic tick".into(), )); @@ -2018,8 +2057,11 @@ fn apply_strategic_tick_plan( }); } + let mut seen_gestalt_migrations = BTreeSet::new(); for action in &plan.gestalt_migrations { - if !seen_gestalts.insert(action.gestalt_id.clone()) { + if !seen_gestalt_migrations.insert(action.gestalt_id.clone()) + || (!canonical_composition && !legacy_seen_gestalts.insert(action.gestalt_id.clone())) + { return Err(KernelError::Invalid( "gestalt acts twice in one strategic tick".into(), )); @@ -2060,8 +2102,11 @@ fn apply_strategic_tick_plan( }); } + let mut seen_gestalt_activities = BTreeSet::new(); for action in plan.gestalt_activities { - if !seen_gestalts.insert(action.gestalt_id.clone()) { + if !seen_gestalt_activities.insert(action.gestalt_id.clone()) + || (!canonical_composition && !legacy_seen_gestalts.insert(action.gestalt_id.clone())) + { return Err(KernelError::Invalid( "gestalt acts twice in one strategic tick".into(), )); @@ -2089,10 +2134,10 @@ fn apply_strategic_tick_plan( || (needs_target && action.target_subject_ids.is_empty()) || action.location_ids.len() > 4 || unique_locations.len() != action.location_ids.len() - || action - .location_ids - .iter() - .any(|location| !profile.location_ids.contains(location)) + || action.location_ids.iter().any(|location| { + !profile.location_ids.contains(location) + && prospective_gestalt_locations.get(&action.gestalt_id) != Some(location) + }) { return Err(KernelError::Invalid( "strategic gestalt activity exceeds exact graph or location scope".into(), @@ -2149,9 +2194,12 @@ fn apply_strategic_tick_plan( }); } - let mut seen_actors = BTreeSet::new(); + let mut legacy_seen_actors = BTreeSet::new(); + let mut seen_actor_moves = BTreeSet::new(); for action in plan.actor_moves { - if !seen_actors.insert(action.actor_id.clone()) { + if !seen_actor_moves.insert(action.actor_id.clone()) + || (!canonical_composition && !legacy_seen_actors.insert(action.actor_id.clone())) + { return Err(KernelError::Invalid( "actor moves twice in one strategic tick".into(), )); @@ -2201,8 +2249,11 @@ fn apply_strategic_tick_plan( }); } + let mut seen_actor_activities = BTreeSet::new(); for action in plan.actor_activities { - if !seen_actors.insert(action.actor_id.clone()) { + if !seen_actor_activities.insert(action.actor_id.clone()) + || (!canonical_composition && !legacy_seen_actors.insert(action.actor_id.clone())) + { return Err(KernelError::Invalid( "actor acts twice in one strategic tick".into(), )); @@ -2229,7 +2280,8 @@ fn apply_strategic_tick_plan( .any(|target| !allowed_targets.contains(target)) || (needs_target && action.target_subject_ids.is_empty()) || action.location_ids.len() != 1 - || action.location_ids[0] != actor.location_id + || (action.location_ids[0] != actor.location_id + && prospective_actor_locations.get(&action.actor_id) != action.location_ids.first()) { return Err(KernelError::Invalid( "strategic actor activity exceeds exact graph or location scope".into(), @@ -2282,9 +2334,12 @@ fn apply_strategic_tick_plan( }); } - let mut seen_members = BTreeSet::new(); + let mut legacy_seen_members = BTreeSet::new(); + let mut seen_member_activities = BTreeSet::new(); for action in plan.member_activities { - if !seen_members.insert(action.member_id.clone()) { + if !seen_member_activities.insert(action.member_id.clone()) + || (!canonical_composition && !legacy_seen_members.insert(action.member_id.clone())) + { return Err(KernelError::Invalid( "gestalt member acts twice in one strategic tick".into(), )); @@ -2316,7 +2371,9 @@ fn apply_strategic_tick_plan( .any(|target| !allowed_targets.contains(target)) || (needs_target && action.target_subject_ids.is_empty()) || action.location_ids.len() != 1 - || action.location_ids[0] != exact_location + || (action.location_ids[0] != exact_location + && prospective_member_locations.get(&action.member_id) + != action.location_ids.first()) { return Err(KernelError::Invalid( "strategic member activity exceeds exact graph or location scope".into(), @@ -2370,8 +2427,11 @@ fn apply_strategic_tick_plan( public_channels: action.public_channels, }); } + let mut seen_member_migrations = BTreeSet::new(); for action in plan.member_migrations { - if !seen_members.insert(action.member_id.clone()) { + if !seen_member_migrations.insert(action.member_id.clone()) + || (!canonical_composition && !legacy_seen_members.insert(action.member_id.clone())) + { return Err(KernelError::Invalid( "gestalt member migrates twice in one strategic tick".into(), )); @@ -3284,6 +3344,89 @@ mod tests { } } + #[test] + fn composite_selected_action_commits_atomically_and_rebuilds_derived_lanes() { + let mut value = campaign(); + value.locations.insert( + "yard".into(), + Location { + id: "yard".into(), + name: "Yard".into(), + container_id: None, + routes: BTreeMap::new(), + persistent_features: vec![], + }, + ); + value.locations.get_mut("room").unwrap().routes.insert( + "yard-route".into(), + crate::domain::Route { + destination_id: "yard".into(), + distance: "nearby".into(), + travel_minutes: 15, + }, + ); + let mut runner = value.actors["player"].clone(); + runner.id = "runner".into(); + runner.name = "Runner".into(); + value.actors.insert(runner.id.clone(), runner); + crate::resolution::ensure_agency_profiles(&mut value); + let proposal = CellActionProposal { + subject_id: "runner".into(), + intent: "cross into the yard and inspect it".into(), + intended_effect: "arrive in the yard and identify one local hazard".into(), + priority: 80, + state_references: vec!["subject:runner".into()], + public_channels: vec![], + effects: vec![ + StrategicCellEffect::ActorMove { + actor_id: "runner".into(), + destination_id: "yard".into(), + }, + StrategicCellEffect::ActorActivity { + actor_id: "runner".into(), + activity: StrategicActivityKind::Investigate, + target_subject_ids: vec![], + location_ids: vec!["yard".into()], + }, + ], + }; + let digest = crate::resolution::cell_action_digest(&proposal).unwrap(); + let mut plan = crate::resolution::project_selected_actions(&value, vec![proposal]).unwrap(); + plan.activity_outcomes = vec![StrategicActivityOutcome { + schema: "ghostlight.strategic_activity_outcome.v1".into(), + action_digest: digest, + source_subject_id: "runner".into(), + band: StrategicOutcomeBand::Success, + summary: "Runner completes the inspection without a durable discovery.".into(), + supporting_state_references: vec![], + effect: StrategicOutcomeEffect::NoMaterialChange { + reason: "The inspection reveals no durable new fact.".into(), + }, + }]; + plan.actor_moves = vec![StrategicActorMove { + actor_id: "player".into(), + destination_id: "invented".into(), + public_channels: vec![], + }]; + + let applied = apply_strategic_tick_plan(&mut value, plan).unwrap(); + + assert_eq!(value.actors["runner"].location_id, "yard"); + assert_eq!(value.actors["player"].location_id, "room"); + assert_eq!( + applied + .events + .iter() + .map(|event| event.kind.as_str()) + .collect::>(), + vec![ + "actor_movement", + "actor_activity", + "strategic_activity_outcome" + ] + ); + } + #[tokio::test] async fn mailbox_commits_one_authorized_component_mutation_batch_atomically() { use crate::transition::*; @@ -6348,11 +6491,11 @@ mod tests { priority: 1, state_references: vec![], public_channels: vec![], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: subject_id, posture: "publishing a bounded new commitment".into(), location_ids: vec![], - }, + }], }]; wave.appraisals[0].inactions.clear(); let mut rejected_verifier = resolution_stage( @@ -6436,12 +6579,12 @@ mod tests { "location:camp".into(), ], public_channels: vec![], - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: "refugees-east".into(), activity: StrategicActivityKind::Prepare, target_subject_ids: vec![], location_ids: vec!["camp".into()], - }, + }], }; let action_digest = crate::resolution::cell_action_digest(&proposal).unwrap(); appraisal.actions = vec![proposal]; diff --git a/crates/ghostlight-dungeon/src/outcome.rs b/crates/ghostlight-dungeon/src/outcome.rs index 3655ccc..bf1ab9d 100644 --- a/crates/ghostlight-dungeon/src/outcome.rs +++ b/crates/ghostlight-dungeon/src/outcome.rs @@ -388,6 +388,26 @@ pub fn validate_plan_activity_outcomes( campaign: &Campaign, plan: &StrategicTickPlan, ) -> Result<()> { + if !plan.selected_actions.is_empty() { + let selected = plan + .selected_actions + .iter() + .filter(|proposal| proposal.effects.iter().any(is_activity_effect)) + .map(|proposal| Ok((cell_action_digest(proposal)?, proposal))) + .collect::>>()?; + if selected.len() + != plan + .selected_actions + .iter() + .filter(|proposal| proposal.effects.iter().any(is_activity_effect)) + .count() + { + return Err(anyhow!( + "selected strategic activities contain duplicate action digests" + )); + } + return validate_outcomes_against_expected(campaign, &selected, &plan.activity_outcomes); + } let synthetic = plan .gestalt_activities .iter() @@ -401,12 +421,12 @@ pub fn validate_plan_activity_outcomes( priority: 0, state_references: vec![], public_channels: activity.public_channels.clone(), - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: activity.gestalt_id.clone(), activity: activity.activity.clone(), target_subject_ids: activity.target_subject_ids.clone(), location_ids: activity.location_ids.clone(), - }, + }], }, ) }) @@ -420,12 +440,12 @@ pub fn validate_plan_activity_outcomes( priority: 0, state_references: vec![], public_channels: activity.public_channels.clone(), - effect: StrategicCellEffect::ActorActivity { + effects: vec![StrategicCellEffect::ActorActivity { actor_id: activity.actor_id.clone(), activity: activity.activity.clone(), target_subject_ids: activity.target_subject_ids.clone(), location_ids: activity.location_ids.clone(), - }, + }], }, ) })) @@ -439,12 +459,12 @@ pub fn validate_plan_activity_outcomes( priority: 0, state_references: vec![], public_channels: activity.public_channels.clone(), - effect: StrategicCellEffect::MemberActivity { + effects: vec![StrategicCellEffect::MemberActivity { member_id: activity.member_id.clone(), activity: activity.activity.clone(), target_subject_ids: activity.target_subject_ids.clone(), location_ids: activity.location_ids.clone(), - }, + }], }, ) })) @@ -1454,7 +1474,19 @@ fn is_human_controlled_actor(campaign: &Campaign, subject_id: &str) -> bool { fn activity_parts( proposal: &CellActionProposal, ) -> Result<(StrategicActivityKind, Vec, Vec)> { - match &proposal.effect { + let mut activities = proposal + .effects + .iter() + .filter(|effect| is_activity_effect(effect)); + let activity = activities + .next() + .ok_or_else(|| anyhow!("strategic outcome was requested for a non-activity"))?; + if activities.next().is_some() { + return Err(anyhow!( + "one strategic action cannot contain multiple activity effects" + )); + } + match activity { StrategicCellEffect::GestaltActivity { activity, target_subject_ids, @@ -1483,6 +1515,15 @@ fn activity_parts( } } +fn is_activity_effect(effect: &StrategicCellEffect) -> bool { + matches!( + effect, + StrategicCellEffect::GestaltActivity { .. } + | StrategicCellEffect::ActorActivity { .. } + | StrategicCellEffect::MemberActivity { .. } + ) +} + fn subject_name(campaign: &Campaign, subject_id: &str) -> Result { campaign .actors @@ -2305,12 +2346,12 @@ mod tests { priority: 50, state_references: vec!["capability:repair nets".into(), "location:dock".into()], public_channels: vec![], - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: "dockers".into(), activity: StrategicActivityKind::Investigate, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }, + }], } } @@ -2369,12 +2410,12 @@ mod tests { "capability:route scouting".into(), ], public_channels: vec![], - effect: StrategicCellEffect::ActorActivity { + effects: vec![StrategicCellEffect::ActorActivity { actor_id: "member:sable".into(), activity: StrategicActivityKind::Investigate, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }, + }], }; let context = build_context(&value, &[action]).unwrap(); @@ -2550,12 +2591,12 @@ mod tests { async fn repeated_current_pressure_uses_the_outcome_semantic_correction_owner() { let value = campaign(); let mut action = proposal(); - action.effect = StrategicCellEffect::GestaltActivity { + action.effects = vec![StrategicCellEffect::GestaltActivity { gestalt_id: "dockers".into(), activity: StrategicActivityKind::Prepare, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }; + }]; let digest = cell_action_digest(&action).unwrap(); let model = RepeatingPressureModel { action_digest: digest, @@ -2647,12 +2688,12 @@ mod tests { priority: 90, state_references: vec!["resource:medical satchel".into()], public_channels: vec![], - effect: StrategicCellEffect::ActorActivity { + effects: vec![StrategicCellEffect::ActorActivity { actor_id: "reed".into(), activity: StrategicActivityKind::Prepare, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }, + }], }, ) .unwrap(); @@ -2665,12 +2706,12 @@ mod tests { fn resource_outcome_reports_the_exact_required_owner() { let value = campaign(); let mut action = proposal(); - action.effect = StrategicCellEffect::GestaltActivity { + action.effects = vec![StrategicCellEffect::GestaltActivity { gestalt_id: "dockers".into(), activity: StrategicActivityKind::Prepare, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }; + }]; let outcome = StrategicActivityOutcome { schema: "ghostlight.strategic_activity_outcome.v1".into(), action_digest: cell_action_digest(&action).unwrap(), @@ -2752,12 +2793,12 @@ mod tests { fn prepare_context_projects_only_locally_admissible_durable_handles() { let value = campaign(); let mut action = proposal(); - action.effect = StrategicCellEffect::GestaltActivity { + action.effects = vec![StrategicCellEffect::GestaltActivity { gestalt_id: "dockers".into(), activity: StrategicActivityKind::Prepare, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }; + }]; let context = action_context(&value, &action).unwrap(); @@ -2823,12 +2864,12 @@ mod tests { priority: 70, state_references: vec!["capability:pull relays".into(), "location:dock".into()], public_channels: vec![], - effect: StrategicCellEffect::MemberActivity { + effects: vec![StrategicCellEffect::MemberActivity { member_id: "mira".into(), activity: StrategicActivityKind::Obstruct, target_subject_ids: vec![], location_ids: vec!["dock".into()], - }, + }], }; let physical = action_context(&value, &action).unwrap(); @@ -2838,7 +2879,7 @@ mod tests { .contains(&OutcomeEffectKind::MemberObligation) ); - if let StrategicCellEffect::MemberActivity { activity, .. } = &mut action.effect { + if let StrategicCellEffect::MemberActivity { activity, .. } = &mut action.effects[0] { *activity = StrategicActivityKind::Communicate; } let social = action_context(&value, &action).unwrap(); diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 79e8c1c..e1c1cb6 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -157,7 +157,8 @@ struct CellActionCandidate { priority: i16, state_references: Vec, public_channels: Vec, - effect: CellEffectCandidate, + #[schemars(length(min = 1, max = 4))] + effects: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -262,10 +263,10 @@ const CELL_APPRAISAL_OUTPUT_CONTRACT: &str = r#"{ "type":"object", "required":["actions","inactions"], "properties":{ - "actions":{"type":"array","items":{"type":"object","required":["subject_id","intent","intended_effect","priority","state_references","public_channels","effect"],"properties":{ + "actions":{"type":"array","items":{"type":"object","required":["subject_id","intent","intended_effect","priority","state_references","public_channels","effects"],"properties":{ "subject_id":{"type":"string"},"intent":{"type":"string"},"intended_effect":{"type":"string"},"priority":{"type":"integer"}, "state_references":{"type":"array","items":{"type":"string"}},"public_channels":{"type":"array","items":{"type":"string"}}, - "effect":{"oneOf":[ + "effects":{"type":"array","minItems":1,"maxItems":4,"items":{"oneOf":[ {"type":"object","required":["type","posture","location_ids"],"properties":{"type":{"const":"institution"},"posture":{"type":"string","minLength":1,"maxLength":240},"location_ids":{"type":"array","items":{"type":"string"}}}}, {"type":"object","required":["type","pressure_additions","pressure_resolutions"],"properties":{"type":{"const":"gestalt"},"pressure_additions":{"type":"array","maxItems":4,"items":{"type":"string"}},"pressure_resolutions":{"type":"array","maxItems":4,"items":{"type":"string"}}}}, {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"gestalt_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":4,"items":{"type":"string"}}}}, @@ -274,7 +275,7 @@ const CELL_APPRAISAL_OUTPUT_CONTRACT: &str = r#"{ {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"actor_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}}, {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"member_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}}, {"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"member_migration"},"destination_gestalt_id":{"type":"string"}}} - ]} + ]}} }}}, "inactions":{"type":"array","items":{"type":"object","required":["subject_id","reason"],"properties":{"subject_id":{"type":"string"},"reason":{"type":"string","minLength":1,"maxLength":240}}}} } @@ -1013,7 +1014,7 @@ impl CellProjectionEngine { let permission_guidance = format!( concat!( "Emit at most {} exact constituent- or named-member-attributed attempts. Priority is an urgency score from 0 to 100 where higher numbers resolve first. ", - "Copy each subject's exact allowed_effect_types. A lane absent from that list is structurally unavailable: do not emit it and do not invent a destination. ", + "Each action carries one to four orthogonal effects in execution order. Copy each subject's exact allowed_effect_types. A lane absent from that list is structurally unavailable: do not emit it and do not invent a destination. When one chosen act includes travel followed by a local attempt at the supplied destination, preserve both means in one action: movement first, then the activity at that exact destination. Never split one subject's single choice into multiple actions. ", "Use gestalt_activity or member_activity for a concrete attempt that does not itself change pressure. Map attempts narrowly: communicate means speak, send, offer, ask, or notify; coordinate means arrange a joint attempt; prepare means the subject's own concrete work; investigate means seek information; recruit means invite; trade means offer an exchange; obstruct means attempt interference. Cite the smallest exact set of state_references that materially supports each attempt; the permission list is an upper bound, not a checklist to echo. ", "target_subject_ids and location_ids must come from that exact subject's permissions. activity_targets is the exact canonical target map: each key is the authoritative ID and each value supplies the target's name and current canonical locations. Use an ID only when the Persona addresses that named target, never merely because the ID is permitted. If an addressed person or role has no matching activity_targets entry, it is not a canonical target in this slice. reachable_destinations maps exact actor-movement destination IDs to names. migration_destinations maps exact population destination IDs to names and locations. When the Persona chooses to go to a canonical target, compare the target's current locations with the acting subject's current location and exact reachable destinations; never guess a destination from an opaque ID. Every activity has at most four unique target_subject_ids; choose the four most causally relevant when more permitted subjects are involved. A member_activity uses exactly the member's source_location_id. Internal work is prepare with no targets. A local investigate may have no target and use the exact current location to seek information from the environment or an unnamed ordinary role; asking an unnamed clerk or dock master for facts maps here and records only the inquiry, never a reply or discovery. A local communicate may likewise have no target at the exact current location when the Persona speaks, sends, offers, asks permission, or notifies an unnamed ordinary role; it records only the source's outgoing attempt, never a listener, reply, acceptance, or outcome. Communication with a canonical subject requires that exact target ID. Never substitute a containing population, related institution, or merely permitted ID for an unnamed role. ", "Write intended_effect as the attempted act, never its hoped-for outcome or target response. Merely waiting, watching, staying, holding position, or remaining ready is attributed inaction, not prepare. prepare requires concrete work on a bounded arrangement, repair, resource, or capability-backed readiness change. Institution posture must be a specific materially new commitment or withholding of at most 240 characters. already_committed_posture is state already in force: maintaining, continuing, or restating it is inaction and must not emit an institution action. Gestalt pressure_resolutions copy exact current_pressures; additions are new unresolved constraints, never completed actions. Use only permitted state references. public_channels means durable publication of this attempt through exact allowed_persistent_publication_channels; it is not a perception method or ordinary local speech. Use [] when that exact list is empty. ", @@ -1081,7 +1082,7 @@ impl CellProjectionEngine { "subject_id":action.subject_id, "intent":action.intent, "intended_effect":action.intended_effect, - "typed_effect":action.effect, + "typed_effects":action.effects, })).collect::>(), "subject_names":slice.constituents.iter().map(|subject|(&subject.subject_id, &subject.name)).chain(slice.member_exceptions.iter().map(|member|(&member.subject_id, &member.name))).collect::>(), "campaign_policy":serde_json::from_str::(&campaign_policy)?, @@ -1098,7 +1099,7 @@ impl CellProjectionEngine { model: self.interpreter_model.clone(), snapshot_binding: verifier_binding, lived_stream: format!( - "You are the private semantic verifier between an Interpreter and the world kernel. Judge each candidate typed effect independently against the exact attributed subject's choice in the Persona turn. Structural permissions were already checked. Use exact_typed_permissions as the sole map of canonical subjects, locations, and destinations. activity_targets supplies each canonical target's exact name and current locations; reachable_destinations supplies exact actor-movement destination IDs and names; migration_destinations supplies exact population names and locations. When the Persona chooses to go to a canonical target, actor_move must use that target's actual different reachable location. If actor and target are already co-located, reject movement to some other place; a local communicate, coordinate, or prepare may encode the stated attempt instead. A place named only in prose and absent from reachable_destinations and migration_destinations is local texture inside the supplied activity location; walking to it cannot justify rejecting a concrete local prepare or repair as omitted travel. Return exactly one verdict for every supplied action_index, in the same order, with no omissions or duplicates. Never reject one action merely because another action is wrong. A gestalt_migration means that exact population leaf chooses to travel together to the supplied destination within the strategic horizon; loading, waiting, giving away passage, sending only some other subject, or merely considering travel does not entail it. Conversely, when the population chooses to board, depart, or relocate together, reject gestalt_activity prepare that erases the chosen journey. Gestalt migration never entails that a named member moved. A member_migration means that named member personally chooses to travel to the destination. Boarding a transport whose supplied destination is unambiguous in the lived stream is a chosen journey; the Persona need not repeat the place name. Giving away a berth, sending somebody else, waiting, or merely considering travel does not entail migration. Conversely, when the member chooses to board, depart, travel, or join the supplied destination, reject member_activity that reduces that commitment to preparing, queuing, or approaching. A member_activity belongs only to that exact named person's stated attempt; it cannot be reassigned to their population. Communication targets must be the exact canonical subjects actually addressed in the Persona turn. If the Persona addresses an unnamed clerk, dock master, passerby, or local environment, reject any effect that substitutes a containing population, related institution, or merely permitted ID. A targetless local investigate at the subject's exact current location is the faithful supported shape for seeking information from an unnamed role or the environment; its empty target list is intentional and must not itself be grounds for rejection. An institution posture must express its stated commitment or withholding. A gestalt pressure resolution must be causally supported by its stated attempt, and an added pressure must be a resulting unresolved condition rather than completed-action prose. An activity records only the exact attempt—never successful preparation, coordination, discovery, recruitment, obstruction, exchange, delivery, persuasion, acceptance, or target response. Reject omissions, reversals, subject swaps, wrong destinations, wishful outcomes, and effects that the Persona did not choose. Be concise. Return exactly one JSON object. Each faithful verdict uses result \"match\", null mismatch_kind, and null repair_guidance. Otherwise use result \"mismatch\", exactly one mismatch_kind (\"subject_swap\", \"effect_omission\", \"effect_reversal\", \"target_substitution\", \"invented_outcome\", or \"wrong_effect_kind\"), and one concrete repair_guidance sentence of at most 240 characters. Name the exact omitted choice, substituted target, or wrong destination. When no supplied typed effect can faithfully encode the choice, explicitly say to remove the action rather than downgrade or redirect it. Shape: {{\"verdicts\":[{{\"action_index\":0,\"result\":\"match\",\"mismatch_kind\":null,\"repair_guidance\":null}}]}}.\n\nCONTEXT:\n{}", + "You are the private semantic verifier between an Interpreter and the world kernel. Judge each candidate action's ordered typed effects as one composition against the exact attributed subject's choice in the Persona turn. Structural permissions were already checked. Use exact_typed_permissions as the sole map of canonical subjects, locations, and destinations. activity_targets supplies each canonical target's exact name and current locations; reachable_destinations supplies exact actor-movement destination IDs and names; migration_destinations supplies exact population names and locations. Preserve every distinct chosen means: travel followed by a local attempt at that supplied destination requires both movement and activity in the same action, in that order. When the Persona chooses to go to a canonical target, actor_move must use that target's actual different reachable location. If actor and target are already co-located, reject movement to some other place; a local communicate, coordinate, or prepare may encode the stated attempt instead. A place named only in prose and absent from reachable_destinations and migration_destinations is local texture inside the supplied activity location; walking to it cannot justify rejecting a concrete local prepare or repair as omitted travel. Return exactly one verdict for every supplied action_index, in the same order, with no omissions or duplicates. Never reject one action merely because another action is wrong. A gestalt_migration means that exact population leaf chooses to travel together to the supplied destination within the strategic horizon; loading, waiting, giving away passage, sending only some other subject, or merely considering travel does not entail it. Conversely, when the population chooses to board, depart, or relocate together, reject gestalt_activity prepare that erases the chosen journey. Gestalt migration never entails that a named member moved. A member_migration means that named member personally chooses to travel to the destination. Boarding a transport whose supplied destination is unambiguous in the lived stream is a chosen journey; the Persona need not repeat the place name. Giving away a berth, sending somebody else, waiting, or merely considering travel does not entail migration. Conversely, when the member chooses to board, depart, travel, or join the supplied destination, reject member_activity that reduces that commitment to preparing, queuing, or approaching. A member_activity belongs only to that exact named person's stated attempt; it cannot be reassigned to their population. Communication targets must be the exact canonical subjects actually addressed in the Persona turn. If the Persona addresses an unnamed clerk, dock master, passerby, or local environment, reject any effect that substitutes a containing population, related institution, or merely permitted ID. A targetless local investigate at the subject's exact current or paired movement destination is the faithful supported shape for seeking information from an unnamed role or the environment; its empty target list is intentional and must not itself be grounds for rejection. An institution posture must express its stated commitment or withholding. A gestalt pressure resolution must be causally supported by its stated attempt, and an added pressure must be a resulting unresolved condition rather than completed-action prose. An activity records only the exact attempt—never successful preparation, coordination, discovery, recruitment, obstruction, exchange, delivery, persuasion, acceptance, or target response. Reject omissions, reversals, subject swaps, wrong destinations, wishful outcomes, and effects that the Persona did not choose. Be concise. Return exactly one JSON object. Each faithful verdict uses result \"match\", null mismatch_kind, and null repair_guidance. Otherwise use result \"mismatch\", exactly one mismatch_kind (\"subject_swap\", \"effect_omission\", \"effect_reversal\", \"target_substitution\", \"invented_outcome\", or \"wrong_effect_kind\"), and one concrete repair_guidance sentence of at most 240 characters. Name the exact omitted choice, substituted target, or wrong destination. When no supplied typed effect composition can faithfully encode the choice, explicitly say to remove the action rather than downgrade or redirect it. Shape: {{\"verdicts\":[{{\"action_index\":0,\"result\":\"match\",\"mismatch_kind\":null,\"repair_guidance\":null}}]}}.\n\nCONTEXT:\n{}", serde_json::to_string(&verifier_context)? ), output_schema: Some(verifier_schema), @@ -1267,7 +1268,7 @@ fn append_cell_correction( ) { let repair_guidance = cell_correction_guidance(error); request.lived_stream.push_str(&format!( - "\n\nCORRECTION TASK—THE PREVIOUS APPRAISAL WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_APPRAISAL:\n{rejected_appraisal}\nReturn one corrected complete appraisal against the same snapshot, lived stream, Persona turn, and exact permission context. The semantic verifier's concrete repair guidance in REJECTION names the exact mismatch and is the primary correction instruction. {repair_guidance} Every retained action must still carry a valid non-empty typed transition under the original contract. If an institution merely continues or restates already_committed_posture, move that exact voiced subject to inactions; it is holding steady. If the Persona chose travel but that subject has no exact permitted destination in reachable_destinations or migration_destinations, no movement transition is available: remove that action and record attributed inaction only when the Persona explicitly holds or waits without making another attempt. If an attempted preparation, inspection, request, or deliberation has no permitted typed consequence, remove it and record attributed inaction only when the Persona explicitly holds or waits without making another attempt; never emit an empty transition or upgrade consideration into a completed consequence. Never add inaction for an unvoiced subject. Keep each reason within 160 characters." + "\n\nCORRECTION TASK—THE PREVIOUS APPRAISAL WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_APPRAISAL:\n{rejected_appraisal}\nReturn one corrected complete appraisal against the same snapshot, lived stream, Persona turn, and exact permission context. The semantic verifier's concrete repair guidance in REJECTION names the exact mismatch and is the primary correction instruction. {repair_guidance} Every retained action must still carry one to four valid orthogonal typed effects under the original contract. Preserve all distinct chosen means in one action; travel followed by a local attempt at the exact destination uses movement then activity. If an institution merely continues or restates already_committed_posture, move that exact voiced subject to inactions; it is holding steady. If the Persona chose travel but that subject has no exact permitted destination in reachable_destinations or migration_destinations, no movement transition is available: remove that action and record attributed inaction only when the Persona explicitly holds or waits without making another attempt. If an attempted preparation, inspection, request, or deliberation has no permitted typed consequence, remove it and record attributed inaction only when the Persona explicitly holds or waits without making another attempt; never emit an empty transition or upgrade consideration into a completed consequence. Never add inaction for an unvoiced subject. Keep each reason within 160 characters." )); } @@ -1285,7 +1286,7 @@ fn rejected_action_diagnostic( "subject_id": action.subject_id, "intent": action.intent, "intended_effect": action.intended_effect, - "typed_effect": action.effect, + "typed_effects": action.effects, }) }) }) @@ -1318,10 +1319,10 @@ fn exclude_rejected_cell_effects( constraints.push(serde_json::json!({ "not":{ "type":"object", - "required":["subject_id", "effect"], + "required":["subject_id", "effects"], "properties":{ "subject_id":{"const":action.subject_id}, - "effect":{"const":cell_effect_candidate_value(&action.effect)} + "effects":{"const":cell_effect_candidate_values(&action.effects)} } } })); @@ -1329,6 +1330,12 @@ fn exclude_rejected_cell_effects( Ok(()) } +fn cell_effect_candidate_values( + effects: &[crate::domain::StrategicCellEffect], +) -> serde_json::Value { + serde_json::Value::Array(effects.iter().map(cell_effect_candidate_value).collect()) +} + fn cell_effect_candidate_value(effect: &crate::domain::StrategicCellEffect) -> serde_json::Value { match effect { crate::domain::StrategicCellEffect::Institution { @@ -1536,60 +1543,64 @@ fn bind_cell_appraisal( .actions .into_iter() .map(|candidate| { - let effect = match candidate.effect { - CellEffectCandidate::Institution { - posture, - location_ids, - } => crate::domain::StrategicCellEffect::Institution { - institution_id: candidate.subject_id.clone(), - posture, - location_ids, - }, - CellEffectCandidate::Gestalt { - pressure_additions, - pressure_resolutions, - } => crate::domain::StrategicCellEffect::Gestalt { - gestalt_id: candidate.subject_id.clone(), - pressure_additions, - pressure_resolutions, - }, - CellEffectCandidate::GestaltActivity { - activity, - target_subject_ids, - location_ids, - } => crate::domain::StrategicCellEffect::GestaltActivity { - gestalt_id: candidate.subject_id.clone(), - activity, - target_subject_ids, - location_ids, - }, - CellEffectCandidate::GestaltMigration { - destination_gestalt_id, - } => crate::domain::StrategicCellEffect::GestaltMigration { - destination_gestalt_id, - }, - CellEffectCandidate::ActorMove { destination_id } => { - crate::domain::StrategicCellEffect::ActorMove { - actor_id: candidate.subject_id.clone(), - destination_id, - } - } - CellEffectCandidate::ActorActivity { - activity, - target_subject_ids, - location_ids, - } => crate::domain::StrategicCellEffect::ActorActivity { - actor_id: candidate.subject_id.clone(), - activity, - target_subject_ids, - location_ids, - }, - CellEffectCandidate::MemberActivity { - activity, - target_subject_ids, - location_ids, - } => { - let member_id = slice + let effects = candidate + .effects + .into_iter() + .map(|effect| { + let effect = match effect { + CellEffectCandidate::Institution { + posture, + location_ids, + } => crate::domain::StrategicCellEffect::Institution { + institution_id: candidate.subject_id.clone(), + posture, + location_ids, + }, + CellEffectCandidate::Gestalt { + pressure_additions, + pressure_resolutions, + } => crate::domain::StrategicCellEffect::Gestalt { + gestalt_id: candidate.subject_id.clone(), + pressure_additions, + pressure_resolutions, + }, + CellEffectCandidate::GestaltActivity { + activity, + target_subject_ids, + location_ids, + } => crate::domain::StrategicCellEffect::GestaltActivity { + gestalt_id: candidate.subject_id.clone(), + activity, + target_subject_ids, + location_ids, + }, + CellEffectCandidate::GestaltMigration { + destination_gestalt_id, + } => crate::domain::StrategicCellEffect::GestaltMigration { + destination_gestalt_id, + }, + CellEffectCandidate::ActorMove { destination_id } => { + crate::domain::StrategicCellEffect::ActorMove { + actor_id: candidate.subject_id.clone(), + destination_id, + } + } + CellEffectCandidate::ActorActivity { + activity, + target_subject_ids, + location_ids, + } => crate::domain::StrategicCellEffect::ActorActivity { + actor_id: candidate.subject_id.clone(), + activity, + target_subject_ids, + location_ids, + }, + CellEffectCandidate::MemberActivity { + activity, + target_subject_ids, + location_ids, + } => { + let member_id = slice .member_exceptions .iter() .find(|member| member.subject_id == candidate.subject_id) @@ -1600,19 +1611,22 @@ fn bind_cell_appraisal( candidate.subject_id ) })?; - crate::domain::StrategicCellEffect::MemberActivity { - member_id, - activity, - target_subject_ids, - location_ids, - } - } - CellEffectCandidate::MemberMigration { - destination_gestalt_id, - } => crate::domain::StrategicCellEffect::MemberMigration { - destination_gestalt_id, - }, - }; + crate::domain::StrategicCellEffect::MemberActivity { + member_id, + activity, + target_subject_ids, + location_ids, + } + } + CellEffectCandidate::MemberMigration { + destination_gestalt_id, + } => crate::domain::StrategicCellEffect::MemberMigration { + destination_gestalt_id, + }, + }; + Ok(effect) + }) + .collect::>>()?; Ok(crate::domain::CellActionProposal { subject_id: candidate.subject_id, intent: candidate.intent, @@ -1620,7 +1634,7 @@ fn bind_cell_appraisal( priority: candidate.priority, state_references: candidate.state_references, public_channels: candidate.public_channels, - effect, + effects, }) }) .collect::>>()?; @@ -1746,7 +1760,22 @@ fn validate_cell_action( &subject.permitted_state_references, &subject.information_channels, )?; - validate_constituent_effect(subject, &action.effect)?; + validate_effect_bundle(&action.effects)?; + let prospective_location = action.effects.iter().find_map(|effect| match effect { + crate::domain::StrategicCellEffect::ActorMove { destination_id, .. } => { + Some(destination_id.as_str()) + } + crate::domain::StrategicCellEffect::GestaltMigration { + destination_gestalt_id, + } => subject + .migration_destinations + .get(destination_gestalt_id) + .map(|destination| destination.location_id.as_str()), + _ => None, + }); + for effect in &action.effects { + validate_constituent_effect(subject, effect, prospective_location)?; + } return Ok(()); } let Some(member) = slice @@ -1761,60 +1790,98 @@ fn validate_cell_action( &member.permitted_state_references, &member.information_channels, )?; - match &action.effect { - crate::domain::StrategicCellEffect::MemberActivity { - member_id, - activity, - target_subject_ids, - location_ids, - } => { - let unique_targets = target_subject_ids.iter().collect::>(); - let needs_target = !activity.allows_targetless_local_attempt(); - if needs_target && target_subject_ids.is_empty() { - return Err(anyhow!( - "named member {} 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 {:?}", - member.member_id, - activity, - member.activity_targets.keys().collect::>() - )); + validate_effect_bundle(&action.effects)?; + let prospective_location = action.effects.iter().find_map(|effect| match effect { + crate::domain::StrategicCellEffect::MemberMigration { + destination_gestalt_id, + } => member + .migration_destinations + .get(destination_gestalt_id) + .map(|destination| destination.location_id.as_str()), + _ => None, + }); + for effect in &action.effects { + match effect { + crate::domain::StrategicCellEffect::MemberActivity { + member_id, + activity, + target_subject_ids, + location_ids, + } => { + let unique_targets = target_subject_ids.iter().collect::>(); + let needs_target = !activity.allows_targetless_local_attempt(); + let activity_location = location_ids.first().map(String::as_str); + if member_id != &member.member_id + || target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !member.activity_targets.contains_key(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() != 1 + || (activity_location != Some(member.source_location_id.as_str()) + && activity_location != prospective_location) + { + return Err(anyhow!( + "named member {} proposed {:?} toward {:?} at {:?}; exact allowed targets are {:?}, current location is {}, and a paired migration may establish {:?}", + member.member_id, + activity, + target_subject_ids, + location_ids, + member.activity_targets.keys().collect::>(), + member.source_location_id, + prospective_location + )); + } } - if member_id != &member.member_id - || target_subject_ids.len() > 4 - || unique_targets.len() != target_subject_ids.len() - || target_subject_ids - .iter() - .any(|target| !member.activity_targets.contains_key(target)) - || location_ids.len() != 1 - || location_ids[0] != member.source_location_id - { + crate::domain::StrategicCellEffect::MemberMigration { + destination_gestalt_id, + } if member + .migration_destinations + .contains_key(destination_gestalt_id) => {} + _ => { return Err(anyhow!( - "named member {} proposed {:?} toward {:?} at {:?}; exact allowed targets (choose at most four unique IDs) are {:?} and location is {:?}", + "action for named member {} exceeds exact personal authority; effects={:?}; allowed destination gestalt IDs={:?}", member.member_id, - activity, - target_subject_ids, - location_ids, - member.activity_targets.keys().collect::>(), - member.source_location_id + action.effects, + member.migration_destinations.keys().collect::>() )); } } - crate::domain::StrategicCellEffect::MemberMigration { - destination_gestalt_id, - } if member - .migration_destinations - .contains_key(destination_gestalt_id) => {} - _ => { + } + Ok(()) +} + +fn validate_effect_bundle(effects: &[crate::domain::StrategicCellEffect]) -> Result<()> { + if effects.is_empty() || effects.len() > 4 { + return Err(anyhow!( + "a strategic action requires one to four exact effects" + )); + } + let mut lanes = BTreeSet::new(); + for effect in effects { + if !lanes.insert(cell_effect_lane(effect)) { return Err(anyhow!( - "action for named member {} exceeds exact personal authority; effect={:?}; allowed destination gestalt IDs={:?}", - member.member_id, - action.effect, - member.migration_destinations.keys().collect::>() + "a strategic action may use each orthogonal effect lane at most once" )); } } Ok(()) } +fn cell_effect_lane(effect: &crate::domain::StrategicCellEffect) -> &'static str { + match effect { + crate::domain::StrategicCellEffect::Institution { .. } => "institution", + crate::domain::StrategicCellEffect::Gestalt { .. } => "gestalt_pressure", + crate::domain::StrategicCellEffect::GestaltActivity { .. } => "gestalt_activity", + crate::domain::StrategicCellEffect::GestaltMigration { .. } => "gestalt_migration", + crate::domain::StrategicCellEffect::ActorMove { .. } => "actor_move", + crate::domain::StrategicCellEffect::ActorActivity { .. } => "actor_activity", + crate::domain::StrategicCellEffect::MemberActivity { .. } => "member_activity", + crate::domain::StrategicCellEffect::MemberMigration { .. } => "member_migration", + } +} + fn validate_active_decision_owners( active_subject_ids: &BTreeSet, appraisal: &crate::domain::CellAppraisal, @@ -1842,6 +1909,7 @@ fn validate_active_decision_owners( fn validate_constituent_effect( subject: &CellConstituentSlice, effect: &crate::domain::StrategicCellEffect, + prospective_location: Option<&str>, ) -> Result<()> { match effect { crate::domain::StrategicCellEffect::Institution { @@ -1942,9 +2010,10 @@ fn validate_constituent_effect( .any(|target| !subject.activity_targets.contains_key(target)) || location_ids.len() > 4 || unique_locations.len() != location_ids.len() - || location_ids - .iter() - .any(|location| !subject.location_ids.contains(location)) + || location_ids.iter().any(|location| { + !subject.location_ids.contains(location) + && Some(location.as_str()) != prospective_location + }) { return Err(anyhow!( "gestalt {} proposed {:?} toward {:?} at {:?}; exact allowed targets (choose at most four unique IDs) are {:?} and exact locations (choose at most four unique IDs) are {:?}", @@ -2008,7 +2077,8 @@ fn validate_constituent_effect( .any(|target| !subject.activity_targets.contains_key(target)) || (needs_target && target_subject_ids.is_empty()) || location_ids.len() != 1 - || !subject.location_ids.contains(&location_ids[0]) + || (!subject.location_ids.contains(&location_ids[0]) + && Some(location_ids[0].as_str()) != prospective_location) { return Err(anyhow!( "actor {} proposed {:?} toward {:?} at {:?}; exact allowed targets are {:?} and exact locations are {:?}", @@ -2168,7 +2238,12 @@ fn exact_cell_action_condition( "properties":{ "state_references":exact_string_array_schema(permitted_state_references, 0, permitted_state_references.len()), "public_channels":exact_string_array_schema(information_channels, 0, 8), - "effect":effect_schema + "effects":{ + "type":"array", + "minItems":1, + "maxItems":4, + "items":effect_schema + } } } }) @@ -2181,17 +2256,29 @@ fn exact_constituent_effect_schema(subject: &CellConstituentSlice) -> serde_json .keys() .cloned() .collect::>(); + let activity_location_ids = subject + .location_ids + .iter() + .cloned() + .chain(subject.reachable_destinations.keys().cloned()) + .chain( + subject + .migration_destinations + .values() + .map(|destination| destination.location_id.clone()), + ) + .collect::>(); let mut constraints = Vec::new(); match subject.subject_kind { crate::domain::AgencySubjectKind::Actor => constraints.push(exact_activity_scope_schema( &activity_target_ids, - &subject.location_ids, + &activity_location_ids, 1, vec!["actor_activity"], )), crate::domain::AgencySubjectKind::Gestalt => constraints.push(exact_activity_scope_schema( &activity_target_ids, - &subject.location_ids, + &activity_location_ids, 0, vec!["gestalt_activity"], )), @@ -2237,9 +2324,17 @@ fn exact_member_effect_schema(member: &CellMemberSlice) -> serde_json::Value { .keys() .cloned() .collect::>(); + let activity_location_ids = std::iter::once(member.source_location_id.clone()) + .chain( + member + .migration_destinations + .values() + .map(|destination| destination.location_id.clone()), + ) + .collect::>(); let mut constraints = vec![exact_activity_scope_schema( &activity_target_ids, - &BTreeSet::from([member.source_location_id.clone()]), + &activity_location_ids, 1, vec!["member_activity"], )]; @@ -2423,7 +2518,7 @@ mod tests { "priority":5, "state_references":["institution:faction-06"], "public_channels":["public bulletin"], - "effect":{"type":"institution","posture":"weighing whether to publish a position","location_ids":["forum"]} + "effects":[{"type":"institution","posture":"weighing whether to publish a position","location_ids":["forum"]}] }], "inactions":[] }) @@ -2437,11 +2532,11 @@ mod tests { "priority":5, "state_references":["institution:faction-06"], "public_channels":["public bulletin"], - "effect":{ + "effects":[{ "type":"institution", "posture":"weighing whether to publish a position", "location_ids":["forum"] - } + }] }], "inactions":[] }); @@ -2471,7 +2566,7 @@ mod tests { "priority":5, "state_references":["institution:faction-06"], "public_channels":["public bulletin"], - "effect":{"type":"institution","posture":"published a bounded position","location_ids":["forum"]} + "effects":[{"type":"institution","posture":"published a bounded position","location_ids":["forum"]}] }], "inactions":[] }).to_string()) @@ -2579,7 +2674,7 @@ mod tests { "semantic_invalid" ); assert!(matches!( - output.appraisal.actions[0].effect, + output.appraisal.actions[0].effects[0], StrategicCellEffect::Institution { .. } )); } @@ -2626,11 +2721,11 @@ mod tests { "priority":5, "state_references":["institution:faction-06"], "public_channels":["public bulletin"], - "effect":{ + "effects":[{ "type":"institution", "posture":if correction {"withholding reserve commitment pending a verified public count"} else {"releases the reserve immediately"}, "location_ids":["forum"] - } + }] }], "inactions":[] }).to_string()) @@ -2703,7 +2798,8 @@ mod tests { output.stage_receipts[3].validation_result, "semantic_invalid" ); - let StrategicCellEffect::Institution { posture, .. } = &output.appraisal.actions[0].effect + let StrategicCellEffect::Institution { posture, .. } = + &output.appraisal.actions[0].effects[0] else { panic!("corrected effect changed type") }; @@ -2741,11 +2837,11 @@ mod tests { priority: 50, state_references: vec!["institution:faction-06".into()], public_channels: vec!["public bulletin".into()], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: "faction-06".into(), posture: "publishing a bounded new commitment".into(), location_ids: vec!["forum".into()], - }, + }], }); assert!( validate_cell_appraisal(&slice, &appraisal) @@ -2817,7 +2913,7 @@ mod tests { "priority":80, "state_references":["subject:relationship-anchor:reed"], "public_channels":[], - "effect":effect + "effects":[effect] }], "inactions":[] }) @@ -2930,11 +3026,11 @@ mod tests { "priority":80, "state_references":exact_references, "public_channels":[], - "effect":{ + "effects":[{ "type":"institution", "posture":"withhold action pending an exact count", "location_ids":[] - } + }] }], "inactions":[] }); @@ -2951,11 +3047,11 @@ mod tests { priority: 1, state_references: vec![], public_channels: vec![], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: "faction-06".into(), posture: "withholding pending verification".into(), location_ids: vec!["forum".into()], - }, + }], }; let mut second = first.clone(); second.intended_effect = "release immediately".into(); @@ -2978,9 +3074,9 @@ mod tests { priority: 80, state_references: vec!["member:reed".into()], public_channels: vec![], - effect: StrategicCellEffect::MemberMigration { + effects: vec![StrategicCellEffect::MemberMigration { destination_gestalt_id: "refugee-encampment".into(), - }, + }], }; let diagnostic = rejected_action_diagnostic(&[action], &[0]); assert!(diagnostic.contains("member:reed")); @@ -3010,10 +3106,10 @@ mod tests { priority: 80, state_references: vec!["institution:faction-06".into()], public_channels: vec![], - effect: StrategicCellEffect::ActorMove { + effects: vec![StrategicCellEffect::ActorMove { actor_id, destination_id: "encampment".into(), - }, + }], }; exclude_rejected_cell_effects(&mut schema, &[rejected], &[0]).unwrap(); let validator = jsonschema::validator_for(&schema).unwrap(); @@ -3026,7 +3122,7 @@ mod tests { "priority":80, "state_references":["institution:faction-06"], "public_channels":[], - "effect":effect, + "effects":[effect], }], "inactions":[], }) @@ -3149,10 +3245,10 @@ mod tests { priority: 1, state_references: vec!["gestalt:crowd".into()], public_channels: vec![], - effect: CellEffectCandidate::Gestalt { + effects: vec![CellEffectCandidate::Gestalt { pressure_additions: vec![], pressure_resolutions: vec![], - }, + }], }], inactions: vec![], }, @@ -3301,11 +3397,11 @@ mod tests { priority: 70, state_references: vec!["member:mira".into()], public_channels: vec![], - effect: CellEffectCandidate::MemberActivity { + effects: vec![CellEffectCandidate::MemberActivity { activity: crate::domain::StrategicActivityKind::Communicate, target_subject_ids: vec!["refugees".into()], location_ids: vec!["forum".into()], - }, + }], }], inactions: vec![], }, @@ -3314,7 +3410,8 @@ mod tests { validate_cell_appraisal(&slice, &appraisal).unwrap(); let mut stolen = appraisal; - let StrategicCellEffect::MemberActivity { member_id, .. } = &mut stolen.actions[0].effect + let StrategicCellEffect::MemberActivity { member_id, .. } = + &mut stolen.actions[0].effects[0] else { unreachable!() }; @@ -3351,11 +3448,11 @@ mod tests { priority: 70, state_references: vec!["subject:actor:liaison".into()], public_channels: vec![], - effect: CellEffectCandidate::ActorActivity { + effects: vec![CellEffectCandidate::ActorActivity { activity: crate::domain::StrategicActivityKind::Communicate, target_subject_ids: vec!["clinic".into()], location_ids: vec!["forum".into()], - }, + }], }], inactions: vec![], }, @@ -3363,7 +3460,7 @@ mod tests { .unwrap(); validate_cell_appraisal(&slice, &appraisal).unwrap(); assert!(matches!( - &appraisal.actions[0].effect, + &appraisal.actions[0].effects[0], StrategicCellEffect::ActorActivity { actor_id, .. } if actor_id == "actor:liaison" )); @@ -3377,11 +3474,11 @@ mod tests { priority: 70, state_references: vec!["subject:actor:liaison".into()], public_channels: vec![], - effect: CellEffectCandidate::MemberActivity { + effects: vec![CellEffectCandidate::MemberActivity { activity: crate::domain::StrategicActivityKind::Communicate, target_subject_ids: vec!["clinic".into()], location_ids: vec!["forum".into()], - }, + }], }], inactions: vec![], }, @@ -3394,6 +3491,54 @@ mod tests { ); } + #[test] + fn actor_can_compose_exact_travel_and_destination_activity() { + let mut slice = fixture_cell_slice(); + let subject = &mut slice.constituents[0]; + subject.subject_id = "actor:director".into(); + subject.subject_kind = AgencySubjectKind::Actor; + subject.name = "Clinic Director".into(); + subject.collective_authority_id = None; + subject.location_ids = BTreeSet::from(["garrison".into()]); + subject.reachable_destinations = + BTreeMap::from([("encampment".into(), "Refugee Encampment".into())]); + subject.permitted_state_references = + BTreeSet::from(["subject:actor:director".into(), "location:garrison".into()]); + subject.current_posture = None; + + let proposal = bind_cell_appraisal( + &slice, + CellAppraisalProposal { + actions: vec![CellActionCandidate { + subject_id: "actor:director".into(), + intent: "travel to the encampment and ask what supplies are needed".into(), + intended_effect: "arrive there and make the request without inventing a reply" + .into(), + priority: 70, + state_references: vec!["subject:actor:director".into()], + public_channels: vec![], + effects: vec![ + CellEffectCandidate::ActorMove { + destination_id: "encampment".into(), + }, + CellEffectCandidate::ActorActivity { + activity: crate::domain::StrategicActivityKind::Communicate, + target_subject_ids: vec![], + location_ids: vec!["encampment".into()], + }, + ], + }], + inactions: vec![], + }, + ) + .unwrap(); + validate_cell_appraisal(&slice, &proposal).unwrap(); + + let mut omitted_travel = proposal; + omitted_travel.actions[0].effects.remove(0); + assert!(validate_cell_appraisal(&slice, &omitted_travel).is_err()); + } + #[test] fn repeated_institution_posture_reports_the_exact_missing_change() { let slice = fixture_cell_slice(); @@ -3479,12 +3624,12 @@ mod tests { append_cell_correction( &mut request, &anyhow::anyhow!("the typed effect was unsupported"), - r#"{"actions":[{"effect":{"type":"gestalt"}}]}"#, + r#"{"actions":[{"effects":[{"type":"gestalt"}]}]}"#, ); assert!( request .lived_stream - .contains("valid non-empty typed transition") + .contains("one to four valid orthogonal typed effects") ); assert!( request diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index 95ab0c0..ecb7536 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -1678,150 +1678,181 @@ pub fn select_resolution_wave( }); let consequence_limit = 16usize.min(usize::from(wave.cover.effective_budget) * 2); let mut used = BTreeSet::new(); - let mut plan = StrategicTickPlan::default(); + let mut selected_actions = Vec::new(); + let mut selected_effect_count = 0usize; let mut activity_proposals = Vec::new(); for proposal in proposals { let key = proposal_target_key(&proposal); if !used.insert(key) { continue; } - let action_digest = cell_action_digest(&proposal)?; - let is_activity = matches!( - proposal.effect, - StrategicCellEffect::GestaltActivity { .. } - | StrategicCellEffect::ActorActivity { .. } - | StrategicCellEffect::MemberActivity { .. } - ); - let selected_activity_proposal = is_activity.then(|| proposal.clone()); - match proposal.effect { - StrategicCellEffect::Institution { - institution_id, - posture, - location_ids, - } => plan.institution_actions.push(StrategicInstitutionAction { - institution_id, - posture, - location_ids, - public_channels: proposal.public_channels, - }), - StrategicCellEffect::Gestalt { - gestalt_id, - pressure_additions, - pressure_resolutions, - } => plan.gestalt_actions.push(StrategicGestaltAction { - gestalt_id, - pressure_additions, - pressure_resolutions, - public_channels: proposal.public_channels, - }), - StrategicCellEffect::GestaltActivity { - gestalt_id, - activity, - target_subject_ids, - location_ids, - } => plan.gestalt_activities.push(StrategicGestaltActivity { - action_digest, - gestalt_id, - activity, - target_subject_ids, - location_ids, - public_channels: proposal.public_channels, - }), - StrategicCellEffect::GestaltMigration { - destination_gestalt_id, - } => { - let gestalt_id = proposal.subject_id.clone(); - let destination_location_id = campaign.gestalts[&destination_gestalt_id] - .home_location_id - .clone(); - plan.gestalt_migrations.push(StrategicGestaltMigration { - gestalt_id, - destination_gestalt_id, - destination_location_id, - public_channels: proposal.public_channels, - }); - } - StrategicCellEffect::ActorMove { - actor_id, - destination_id, - } => plan.actor_moves.push(StrategicActorMove { - actor_id, - destination_id, - public_channels: proposal.public_channels, - }), - StrategicCellEffect::ActorActivity { - actor_id, - activity, - target_subject_ids, - location_ids, - } => plan.actor_activities.push(StrategicActorActivity { - action_digest, - actor_id, - activity, - target_subject_ids, - location_ids, - public_channels: proposal.public_channels, - }), - StrategicCellEffect::MemberActivity { - member_id, - activity, - target_subject_ids, - location_ids, - } => { - let source_gestalt_id = campaign.gestalt_members[&member_id].gestalt_id.clone(); - plan.member_activities.push(StrategicMemberActivity { - action_digest, - member_id, - source_gestalt_id, - activity, - target_subject_ids, - location_ids, - public_channels: proposal.public_channels, - }); - } - StrategicCellEffect::MemberMigration { - destination_gestalt_id, - } => { - let member_id = proposal - .subject_id - .strip_prefix("member:") - .expect("member migration authority was validated") - .to_owned(); - let source_gestalt_id = campaign.gestalt_members[&member_id].gestalt_id.clone(); - let destination_location_id = campaign.gestalts[&destination_gestalt_id] - .home_location_id - .clone(); - plan.member_migrations.push(StrategicMemberMigration { - member_id, - source_gestalt_id, - destination_gestalt_id, - destination_location_id, - public_channels: proposal.public_channels, - }); - } + if selected_effect_count.saturating_add(proposal.effects.len()) > consequence_limit { + continue; } + let is_activity = proposal.effects.iter().any(|effect| { + matches!( + effect, + StrategicCellEffect::GestaltActivity { .. } + | StrategicCellEffect::ActorActivity { .. } + | StrategicCellEffect::MemberActivity { .. } + ) + }); + let selected_activity_proposal = is_activity.then(|| proposal.clone()); + selected_effect_count += proposal.effects.len(); + selected_actions.push(proposal); if let Some(proposal) = selected_activity_proposal { activity_proposals.push(proposal); } - if plan.institution_actions.len() - + plan.gestalt_actions.len() - + plan.gestalt_activities.len() - + plan.gestalt_migrations.len() - + plan.actor_moves.len() - + plan.actor_activities.len() - + plan.member_activities.len() - + plan.member_migrations.len() - >= consequence_limit - { - break; - } } + let plan = project_selected_actions(campaign, selected_actions)?; Ok(ResolutionWaveSelection { plan, activity_proposals, }) } +pub(crate) fn project_selected_actions( + campaign: &Campaign, + selected_actions: Vec, +) -> Result { + let mut plan = StrategicTickPlan { + selected_actions: selected_actions.clone(), + ..StrategicTickPlan::default() + }; + for proposal in selected_actions { + let action_digest = cell_action_digest(&proposal)?; + for effect in proposal.effects { + match effect { + StrategicCellEffect::Institution { + institution_id, + posture, + location_ids, + } => plan.institution_actions.push(StrategicInstitutionAction { + institution_id, + posture, + location_ids, + public_channels: proposal.public_channels.clone(), + }), + StrategicCellEffect::Gestalt { + gestalt_id, + pressure_additions, + pressure_resolutions, + } => plan.gestalt_actions.push(StrategicGestaltAction { + gestalt_id, + pressure_additions, + pressure_resolutions, + public_channels: proposal.public_channels.clone(), + }), + StrategicCellEffect::GestaltActivity { + gestalt_id, + activity, + target_subject_ids, + location_ids, + } => plan.gestalt_activities.push(StrategicGestaltActivity { + action_digest: action_digest.clone(), + gestalt_id, + activity, + target_subject_ids, + location_ids, + public_channels: proposal.public_channels.clone(), + }), + StrategicCellEffect::GestaltMigration { + destination_gestalt_id, + } => { + let gestalt_id = proposal.subject_id.clone(); + if !campaign.gestalts.contains_key(&gestalt_id) { + return Err(anyhow!("strategic migration source vanished")); + } + let destination_location_id = campaign + .gestalts + .get(&destination_gestalt_id) + .ok_or_else(|| anyhow!("strategic migration destination vanished"))? + .home_location_id + .clone(); + plan.gestalt_migrations.push(StrategicGestaltMigration { + gestalt_id, + destination_gestalt_id, + destination_location_id, + public_channels: proposal.public_channels.clone(), + }); + } + StrategicCellEffect::ActorMove { + actor_id, + destination_id, + } => plan.actor_moves.push(StrategicActorMove { + actor_id, + destination_id, + public_channels: proposal.public_channels.clone(), + }), + StrategicCellEffect::ActorActivity { + actor_id, + activity, + target_subject_ids, + location_ids, + } => plan.actor_activities.push(StrategicActorActivity { + action_digest: action_digest.clone(), + actor_id, + activity, + target_subject_ids, + location_ids, + public_channels: proposal.public_channels.clone(), + }), + StrategicCellEffect::MemberActivity { + member_id, + activity, + target_subject_ids, + location_ids, + } => { + let source_gestalt_id = campaign + .gestalt_members + .get(&member_id) + .ok_or_else(|| anyhow!("strategic member vanished"))? + .gestalt_id + .clone(); + plan.member_activities.push(StrategicMemberActivity { + action_digest: action_digest.clone(), + member_id, + source_gestalt_id, + activity, + target_subject_ids, + location_ids, + public_channels: proposal.public_channels.clone(), + }); + } + StrategicCellEffect::MemberMigration { + destination_gestalt_id, + } => { + let member_id = proposal + .subject_id + .strip_prefix("member:") + .ok_or_else(|| anyhow!("member migration lacks exact member authority"))? + .to_owned(); + let source_gestalt_id = campaign + .gestalt_members + .get(&member_id) + .ok_or_else(|| anyhow!("strategic member vanished"))? + .gestalt_id + .clone(); + let destination_location_id = campaign + .gestalts + .get(&destination_gestalt_id) + .ok_or_else(|| anyhow!("member migration destination vanished"))? + .home_location_id + .clone(); + plan.member_migrations.push(StrategicMemberMigration { + member_id, + source_gestalt_id, + destination_gestalt_id, + destination_location_id, + public_channels: proposal.public_channels.clone(), + }); + } + } + } + } + Ok(plan) +} + pub fn validate_and_resolve_wave( campaign: &Campaign, wave: &ResolutionWaveCommit, @@ -1845,6 +1876,8 @@ fn validate_cell_proposal( || proposal.intent.trim().is_empty() || proposal.intended_effect.trim().is_empty() || !(0..=100).contains(&proposal.priority) + || proposal.effects.is_empty() + || proposal.effects.len() > 4 { return Err(anyhow!("cell proposal has no exact constituent authority")); } @@ -1872,147 +1905,169 @@ fn validate_cell_proposal( "cell proposal exceeds constituent knowledge, resources, or information scope" )); } - match &proposal.effect { - StrategicCellEffect::Institution { - institution_id, - posture, - location_ids, - } => { - if institution_id != &proposal.subject_id - || !campaign.institutions.contains_key(institution_id) - || posture.trim().is_empty() - || posture.len() > 240 - || !substantive_text_change(&campaign.institutions[institution_id].posture, posture) - || location_ids - .iter() - .any(|id| !campaign.locations.contains_key(id)) - || location_ids - .iter() - .any(|id| !profile.location_ids.contains(id)) - { - return Err(anyhow!("institution proposal exceeds constituent state")); - } - } - StrategicCellEffect::Gestalt { - gestalt_id, - pressure_additions, - pressure_resolutions, - } => { - if gestalt_id != &proposal.subject_id - || !campaign.gestalts.contains_key(gestalt_id) - || validate_gestalt_pressure_transition( - &campaign.gestalts[gestalt_id].pressures, - pressure_additions, - pressure_resolutions, - ) - .is_err() - { - return Err(anyhow!("gestalt proposal exceeds constituent state")); - } - } - StrategicCellEffect::GestaltActivity { - gestalt_id, - activity, - target_subject_ids, - location_ids, - } => { - 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(); - if gestalt_id != &proposal.subject_id - || !campaign.gestalts.contains_key(gestalt_id) - || target_subject_ids.len() > 4 - || unique_targets.len() != target_subject_ids.len() - || target_subject_ids - .iter() - .any(|target| !allowed_targets.contains(target)) - || (needs_target && target_subject_ids.is_empty()) - || location_ids.len() > 4 - || unique_locations.len() != location_ids.len() - || location_ids - .iter() - .any(|location| !profile.location_ids.contains(location)) - { - return Err(anyhow!( - "gestalt activity exceeds exact subject, graph, or location scope" - )); - } - } + validate_strategic_effect_lanes(&proposal.effects)?; + let prospective_location = proposal.effects.iter().find_map(|effect| match effect { + StrategicCellEffect::ActorMove { destination_id, .. } => Some(destination_id.as_str()), StrategicCellEffect::GestaltMigration { destination_gestalt_id, - } => { - let destination_location_id = campaign - .gestalts - .get(destination_gestalt_id) - .map(|gestalt| gestalt.home_location_id.as_str()) - .ok_or_else(|| anyhow!("gestalt migration invented a destination population"))?; - validate_gestalt_migration( - campaign, - &proposal.subject_id, - destination_gestalt_id, - destination_location_id, - )?; - } - StrategicCellEffect::ActorMove { - actor_id, - destination_id, - } => { - let actor = campaign - .actors - .get(actor_id) - .filter(|_| actor_id == &proposal.subject_id) - .ok_or_else(|| anyhow!("actor proposal exceeds constituent state"))?; - let reachable = campaign - .locations - .get(&actor.location_id) - .is_some_and(|location| { - location.routes.values().any(|route| { - route.destination_id == *destination_id - && route.travel_minutes <= campaign.tick_hours.saturating_mul(60) - }) - }); - if !reachable { - return Err(anyhow!("actor proposal exceeds spatial reach")); + } => campaign + .gestalts + .get(destination_gestalt_id) + .map(|gestalt| gestalt.home_location_id.as_str()), + _ => None, + }); + for effect in &proposal.effects { + match effect { + StrategicCellEffect::Institution { + institution_id, + posture, + location_ids, + } => { + if institution_id != &proposal.subject_id + || !campaign.institutions.contains_key(institution_id) + || posture.trim().is_empty() + || posture.len() > 240 + || !substantive_text_change( + &campaign.institutions[institution_id].posture, + posture, + ) + || location_ids + .iter() + .any(|id| !campaign.locations.contains_key(id)) + || location_ids + .iter() + .any(|id| !profile.location_ids.contains(id)) + { + return Err(anyhow!("institution proposal exceeds constituent state")); + } } - } - StrategicCellEffect::ActorActivity { - actor_id, - activity, - target_subject_ids, - location_ids, - } => { - let actor = campaign - .actors - .get(actor_id) - .filter(|_| actor_id == &proposal.subject_id) - .ok_or_else(|| anyhow!("actor activity exceeds constituent state"))?; - let allowed_targets = strategic_activity_targets(campaign, actor_id); - let unique_targets = target_subject_ids.iter().collect::>(); - let needs_target = !activity.allows_targetless_local_attempt(); - if target_subject_ids.len() > 4 - || unique_targets.len() != target_subject_ids.len() - || target_subject_ids - .iter() - .any(|target| !allowed_targets.contains(target)) - || (needs_target && target_subject_ids.is_empty()) - || location_ids.len() != 1 - || location_ids[0] != actor.location_id - { + StrategicCellEffect::Gestalt { + gestalt_id, + pressure_additions, + pressure_resolutions, + } => { + if gestalt_id != &proposal.subject_id + || !campaign.gestalts.contains_key(gestalt_id) + || validate_gestalt_pressure_transition( + &campaign.gestalts[gestalt_id].pressures, + pressure_additions, + pressure_resolutions, + ) + .is_err() + { + return Err(anyhow!("gestalt proposal exceeds constituent state")); + } + } + StrategicCellEffect::GestaltActivity { + gestalt_id, + activity, + target_subject_ids, + location_ids, + } => { + 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(); + if gestalt_id != &proposal.subject_id + || !campaign.gestalts.contains_key(gestalt_id) + || target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !allowed_targets.contains(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() > 4 + || unique_locations.len() != location_ids.len() + || location_ids.iter().any(|location| { + !profile.location_ids.contains(location) + && Some(location.as_str()) != prospective_location + }) + { + return Err(anyhow!( + "gestalt activity exceeds exact subject, graph, or location scope" + )); + } + } + StrategicCellEffect::GestaltMigration { + destination_gestalt_id, + } => { + let destination_location_id = campaign + .gestalts + .get(destination_gestalt_id) + .map(|gestalt| gestalt.home_location_id.as_str()) + .ok_or_else(|| { + anyhow!("gestalt migration invented a destination population") + })?; + validate_gestalt_migration( + campaign, + &proposal.subject_id, + destination_gestalt_id, + destination_location_id, + )?; + } + StrategicCellEffect::ActorMove { + actor_id, + destination_id, + } => { + let actor = campaign + .actors + .get(actor_id) + .filter(|_| actor_id == &proposal.subject_id) + .ok_or_else(|| anyhow!("actor proposal exceeds constituent state"))?; + let reachable = + campaign + .locations + .get(&actor.location_id) + .is_some_and(|location| { + location.routes.values().any(|route| { + route.destination_id == *destination_id + && route.travel_minutes + <= campaign.tick_hours.saturating_mul(60) + }) + }); + if !reachable { + return Err(anyhow!("actor proposal exceeds spatial reach")); + } + } + StrategicCellEffect::ActorActivity { + actor_id, + activity, + target_subject_ids, + location_ids, + } => { + let actor = campaign + .actors + .get(actor_id) + .filter(|_| actor_id == &proposal.subject_id) + .ok_or_else(|| anyhow!("actor activity exceeds constituent state"))?; + let allowed_targets = strategic_activity_targets(campaign, actor_id); + let unique_targets = target_subject_ids.iter().collect::>(); + let needs_target = !activity.allows_targetless_local_attempt(); + if target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !allowed_targets.contains(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() != 1 + || (location_ids[0] != actor.location_id + && Some(location_ids[0].as_str()) != prospective_location) + { + return Err(anyhow!( + "actor activity exceeds exact subject, graph, or location scope" + )); + } + } + StrategicCellEffect::MemberMigration { .. } => { return Err(anyhow!( - "actor activity exceeds exact subject, graph, or location scope" + "a population, institution, actor, or arena cannot migrate a named member" + )); + } + StrategicCellEffect::MemberActivity { .. } => { + return Err(anyhow!( + "a population, institution, actor, or arena cannot act as a named member" )); } - } - StrategicCellEffect::MemberMigration { .. } => { - return Err(anyhow!( - "a population, institution, actor, or arena cannot migrate a named member" - )); - } - StrategicCellEffect::MemberActivity { .. } => { - return Err(anyhow!( - "a population, institution, actor, or arena cannot act as a named member" - )); } } Ok(()) @@ -2147,53 +2202,70 @@ fn validate_member_cell_proposal( "named member proposal borrowed another subject's state or information channel" )); } - match &proposal.effect { - StrategicCellEffect::MemberActivity { - member_id: effect_member_id, - activity, - target_subject_ids, - location_ids, - } => { - let allowed_targets = member_activity_targets(campaign, member_id)?; - let unique_targets = target_subject_ids.iter().collect::>(); - let exact_location = dormant_member_location(campaign, member_id)?; - let needs_target = !activity.allows_targetless_local_attempt(); - if effect_member_id != member_id - || target_subject_ids.len() > 4 - || unique_targets.len() != target_subject_ids.len() - || target_subject_ids - .iter() - .any(|target| !allowed_targets.contains(target)) - || (needs_target && target_subject_ids.is_empty()) - || location_ids.len() != 1 - || location_ids[0] != exact_location - { - return Err(anyhow!( - "named member activity exceeds exact personal, graph, or location scope" - )); - } - Ok(()) - } + validate_strategic_effect_lanes(&proposal.effects)?; + let prospective_location = proposal.effects.iter().find_map(|effect| match effect { StrategicCellEffect::MemberMigration { destination_gestalt_id, - } => { - let destination_location_id = campaign - .gestalts - .get(destination_gestalt_id) - .map(|gestalt| gestalt.home_location_id.as_str()) - .ok_or_else(|| anyhow!("named member migration invented a destination gestalt"))?; - validate_member_migration( - campaign, - member_id, - &member.gestalt_id, + } => campaign + .gestalts + .get(destination_gestalt_id) + .map(|gestalt| gestalt.home_location_id.as_str()), + _ => None, + }); + for effect in &proposal.effects { + match effect { + StrategicCellEffect::MemberActivity { + member_id: effect_member_id, + activity, + target_subject_ids, + location_ids, + } => { + let allowed_targets = member_activity_targets(campaign, member_id)?; + let unique_targets = target_subject_ids.iter().collect::>(); + let exact_location = dormant_member_location(campaign, member_id)?; + let needs_target = !activity.allows_targetless_local_attempt(); + if effect_member_id != member_id + || target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !allowed_targets.contains(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() != 1 + || (location_ids[0] != exact_location + && Some(location_ids[0].as_str()) != prospective_location) + { + return Err(anyhow!( + "named member activity exceeds exact personal, graph, or location scope" + )); + } + } + StrategicCellEffect::MemberMigration { destination_gestalt_id, - destination_location_id, - ) + } => { + let destination_location_id = campaign + .gestalts + .get(destination_gestalt_id) + .map(|gestalt| gestalt.home_location_id.as_str()) + .ok_or_else(|| { + anyhow!("named member migration invented a destination gestalt") + })?; + validate_member_migration( + campaign, + member_id, + &member.gestalt_id, + destination_gestalt_id, + destination_location_id, + )?; + } + _ => { + return Err(anyhow!( + "named member exception may propose only its own validated activity or migration" + )); + } } - _ => Err(anyhow!( - "named member exception may propose only its own validated activity or migration" - )), } + Ok(()) } pub fn validate_gestalt_migration( @@ -2687,23 +2759,31 @@ pub fn dormant_member_location(campaign: &Campaign, member_id: &str) -> Result String { - match &proposal.effect { - StrategicCellEffect::Institution { institution_id, .. } => { - format!("institution:{institution_id}") + proposal.subject_id.clone() +} + +fn validate_strategic_effect_lanes(effects: &[StrategicCellEffect]) -> Result<()> { + let mut lanes = BTreeSet::new(); + for effect in effects { + if !lanes.insert(strategic_effect_lane(effect)) { + return Err(anyhow!( + "one strategic action may use each orthogonal effect lane at most once" + )); } - StrategicCellEffect::Gestalt { gestalt_id, .. } => format!("gestalt:{gestalt_id}"), - StrategicCellEffect::GestaltActivity { gestalt_id, .. } => { - format!("gestalt:{gestalt_id}") - } - StrategicCellEffect::GestaltMigration { .. } => { - format!("gestalt:{}", proposal.subject_id) - } - StrategicCellEffect::ActorMove { actor_id, .. } => format!("actor:{actor_id}"), - StrategicCellEffect::ActorActivity { actor_id, .. } => format!("actor:{actor_id}"), - StrategicCellEffect::MemberActivity { member_id, .. } => { - format!("member:{member_id}") - } - StrategicCellEffect::MemberMigration { .. } => proposal.subject_id.clone(), + } + Ok(()) +} + +fn strategic_effect_lane(effect: &StrategicCellEffect) -> &'static str { + match effect { + StrategicCellEffect::Institution { .. } => "institution", + StrategicCellEffect::Gestalt { .. } => "gestalt_pressure", + StrategicCellEffect::GestaltActivity { .. } => "gestalt_activity", + StrategicCellEffect::GestaltMigration { .. } => "gestalt_migration", + StrategicCellEffect::ActorMove { .. } => "actor_move", + StrategicCellEffect::ActorActivity { .. } => "actor_activity", + StrategicCellEffect::MemberActivity { .. } => "member_activity", + StrategicCellEffect::MemberMigration { .. } => "member_migration", } } @@ -3089,12 +3169,12 @@ pub(crate) mod tests { "capability:route scouting".into(), ], public_channels: vec![], - effect: StrategicCellEffect::ActorActivity { + effects: vec![StrategicCellEffect::ActorActivity { actor_id: "member:sable".into(), activity: StrategicActivityKind::Investigate, target_subject_ids: vec![], location_ids: vec!["center".into()], - }, + }], }; validate_cell_proposal(&value, &actor_cell, &proposal).unwrap(); @@ -3370,17 +3450,18 @@ pub(crate) mod tests { priority: 50, state_references: vec!["subject:liaison".into()], public_channels: vec![], - effect: StrategicCellEffect::ActorActivity { + effects: vec![StrategicCellEffect::ActorActivity { actor_id: "liaison".into(), activity: StrategicActivityKind::Investigate, target_subject_ids: vec![], location_ids: vec!["center".into()], - }, + }], }; validate_cell_proposal(&value, &cell, &proposal).unwrap(); let mut wrong_location = proposal.clone(); - let StrategicCellEffect::ActorActivity { location_ids, .. } = &mut wrong_location.effect + let StrategicCellEffect::ActorActivity { location_ids, .. } = + &mut wrong_location.effects[0] else { unreachable!() }; @@ -3389,13 +3470,82 @@ pub(crate) mod tests { let mut player_puppet = proposal; player_puppet.subject_id = "player".into(); - let StrategicCellEffect::ActorActivity { actor_id, .. } = &mut player_puppet.effect else { + let StrategicCellEffect::ActorActivity { actor_id, .. } = &mut player_puppet.effects[0] + else { unreachable!() }; *actor_id = "player".into(); assert!(validate_cell_proposal(&value, &cell, &player_puppet).is_err()); } + #[test] + fn selected_action_projects_travel_and_destination_activity_as_one_choice() { + let mut value = campaign(0, 1); + value.locations.insert( + "encampment".into(), + Location { + id: "encampment".into(), + name: "Refugee Encampment".into(), + container_id: None, + routes: BTreeMap::new(), + persistent_features: vec![], + }, + ); + value.locations.get_mut("center").unwrap().routes.insert( + "to-encampment".into(), + Route { + destination_id: "encampment".into(), + distance: "nearby".into(), + travel_minutes: 30, + }, + ); + let mut liaison = value.actors["player"].clone(); + liaison.id = "liaison".into(); + liaison.name = "Liaison".into(); + value.actors.insert(liaison.id.clone(), liaison); + ensure_agency_profiles(&mut value); + let cell = SimulationCell { + schema: "ghostlight.simulation_cell.v1".into(), + id: "cell:liaison".into(), + mode: SimulationCellMode::Cohesive, + subject_ids: BTreeSet::from(["liaison".into()]), + merge_loss: MergeLoss::default(), + rationale: "exact actor fixture".into(), + lease_until_world_revision: 0, + lease_until_strategic_tick: 0, + detail_focus_subject_id: Some("liaison".into()), + }; + let proposal = CellActionProposal { + subject_id: "liaison".into(), + intent: "travel to the encampment and ask about its shortage".into(), + intended_effect: "arrive and make one local request".into(), + priority: 70, + state_references: vec!["subject:liaison".into()], + public_channels: vec![], + effects: vec![ + StrategicCellEffect::ActorMove { + actor_id: "liaison".into(), + destination_id: "encampment".into(), + }, + StrategicCellEffect::ActorActivity { + actor_id: "liaison".into(), + activity: StrategicActivityKind::Communicate, + target_subject_ids: vec![], + location_ids: vec!["encampment".into()], + }, + ], + }; + + validate_cell_proposal(&value, &cell, &proposal).unwrap(); + let digest = cell_action_digest(&proposal).unwrap(); + let plan = project_selected_actions(&value, vec![proposal.clone()]).unwrap(); + assert_eq!(plan.selected_actions, vec![proposal]); + assert_eq!(plan.actor_moves.len(), 1); + assert_eq!(plan.actor_activities.len(), 1); + assert_eq!(plan.actor_activities[0].action_digest, digest); + assert_eq!(plan.actor_activities[0].location_ids, vec!["encampment"]); + } + #[test] fn partitions_one_thousand_subjects_with_bounded_local_work() { let value = campaign(1_000, 8); @@ -3459,11 +3609,11 @@ pub(crate) mod tests { priority: 1, state_references: vec![], public_channels: vec![], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: "faction-0000".into(), posture: "unified".into(), location_ids: vec![], - }, + }], }; assert!(validate_and_resolve_wave(&value, &make_wave(collective)).is_err()); let borrowed_secret = CellActionProposal { @@ -3473,11 +3623,11 @@ pub(crate) mod tests { priority: 1, state_references: vec![], public_channels: vec!["secret-courier".into()], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: "faction-0000".into(), posture: "messaging".into(), location_ids: vec![], - }, + }], }; assert!(validate_and_resolve_wave(&value, &make_wave(borrowed_secret)).is_err()); @@ -3488,11 +3638,11 @@ pub(crate) mod tests { priority: 50, state_references: vec![], public_channels: vec![], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: "faction-0000".into(), posture: "publishing a bounded position under the current pressure".into(), location_ids: vec![], - }, + }], }; let mut mixed = make_wave(valid_action.clone()); mixed.appraisals[0].inactions = vec![CellInaction { @@ -3604,9 +3754,9 @@ pub(crate) mod tests { "knowledge:the player helped me".into(), ], public_channels: vec![], - effect: StrategicCellEffect::MemberMigration { + effects: vec![StrategicCellEffect::MemberMigration { destination_gestalt_id: "dockers".into(), - }, + }], }; let make_wave = |proposal: CellActionProposal| ResolutionWaveCommit { schema: "ghostlight.resolution_wave_commit.v1".into(), @@ -3636,12 +3786,12 @@ pub(crate) mod tests { priority: 90, state_references: vec!["member:mira".into()], public_channels: vec!["camp-bulletin".into()], - effect: StrategicCellEffect::MemberActivity { + effects: vec![StrategicCellEffect::MemberActivity { member_id: "mira".into(), activity: StrategicActivityKind::Communicate, target_subject_ids: vec!["refugees".into()], location_ids: vec!["center".into()], - }, + }], }; let activity_plan = select_resolution_wave(&value, &make_wave(member_activity.clone())) .unwrap() @@ -3652,7 +3802,7 @@ pub(crate) mod tests { let mut local_member_communication = member_activity.clone(); let StrategicCellEffect::MemberActivity { target_subject_ids, .. - } = &mut local_member_communication.effect + } = &mut local_member_communication.effects[0] else { unreachable!() }; @@ -3708,12 +3858,12 @@ pub(crate) mod tests { priority: 80, state_references: vec!["subject:refugees".into()], public_channels: vec!["camp-bulletin".into()], - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: "refugees".into(), activity: StrategicActivityKind::Obstruct, target_subject_ids: vec!["dockers".into()], location_ids: vec!["center".into()], - }, + }], }; let activity_plan = select_resolution_wave(&value, &make_wave(exact_rival_activity.clone())) @@ -3729,11 +3879,11 @@ pub(crate) mod tests { priority: 20, state_references: vec!["subject:refugees".into()], public_channels: vec![], - effect: StrategicCellEffect::Gestalt { + effects: vec![StrategicCellEffect::Gestalt { gestalt_id: "refugees".into(), pressure_additions: vec!["the dockers' exclusion remains unresolved".into()], pressure_resolutions: vec![], - }, + }], }; let same_subject_wave = ResolutionWaveCommit { schema: "ghostlight.resolution_wave_commit.v1".into(), @@ -3766,7 +3916,7 @@ pub(crate) mod tests { let mut invented_target = exact_rival_activity; let StrategicCellEffect::GestaltActivity { target_subject_ids, .. - } = &mut invented_target.effect + } = &mut invented_target.effects[0] else { unreachable!() }; @@ -3781,12 +3931,12 @@ pub(crate) mod tests { priority: 75, state_references: vec!["subject:dockers".into()], public_channels: vec![], - effect: StrategicCellEffect::GestaltActivity { + effects: vec![StrategicCellEffect::GestaltActivity { gestalt_id: "dockers".into(), activity: StrategicActivityKind::Communicate, target_subject_ids: vec!["member:mira".into()], location_ids: vec!["center".into()], - }, + }], }; let address_plan = select_resolution_wave(&value, &make_wave(address_mira)) .unwrap() @@ -3812,11 +3962,11 @@ pub(crate) mod tests { priority, state_references: vec![], public_channels: vec![], - effect: StrategicCellEffect::Institution { + effects: vec![StrategicCellEffect::Institution { institution_id: subject_id.into(), posture: format!("urgent posture {priority}"), location_ids: vec![], - }, + }], }; let wave = ResolutionWaveCommit { schema: "ghostlight.resolution_wave_commit.v1".into(),