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 eac3928..7a2e706 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -413,7 +413,11 @@ fn dynamics_campaign() -> ghostlight_dungeon::domain::Campaign { id.clone(), InstitutionState { id, - name: format!("Regional Power {index:02}"), + name: match index { + 0 => "Port Authority".into(), + 1 => "Relief Union".into(), + _ => format!("Regional Power {index:02}"), + }, resources: vec![ format!("regional reserve {index:02}"), "public harbor bulletin".into(), diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 03dd611..5bf9182 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -427,7 +427,10 @@ pub enum StrategicCellEffect { }, Gestalt { gestalt_id: String, + #[serde(default)] pressure_additions: Vec, + #[serde(default)] + pressure_resolutions: Vec, }, ActorMove { actor_id: String, @@ -591,6 +594,7 @@ pub struct StrategicInstitutionAction { pub struct StrategicGestaltAction { pub gestalt_id: String, pub pressure_additions: Vec, + pub pressure_resolutions: Vec, pub public_channels: Vec, } diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 5785668..1c00f92 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1195,7 +1195,7 @@ fn apply_strategic_tick_plan( "institution acts twice in one strategic tick".into(), )); } - if action.posture.trim().is_empty() { + if action.posture.trim().is_empty() || action.posture.len() > 240 { return Err(KernelError::Invalid( "strategic institution posture is empty".into(), )); @@ -1214,6 +1214,11 @@ fn apply_strategic_tick_plan( .institutions .get_mut(&action.institution_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented an institution".into()))?; + if !crate::resolution::substantive_text_change(&institution.posture, &action.posture) { + return Err(KernelError::Invalid( + "strategic institution action would not change its posture".into(), + )); + } institution.posture = action.posture; let summary = format!( "{} adopts posture: {}", @@ -1238,37 +1243,43 @@ fn apply_strategic_tick_plan( "gestalt acts twice in one strategic tick".into(), )); } - if action.pressure_additions.is_empty() - || action.pressure_additions.len() > 4 - || action - .pressure_additions - .iter() - .any(|p| p.trim().is_empty() || p.len() > 240) - { - return Err(KernelError::Invalid( - "strategic gestalt action is invalid".into(), - )); - } validate_public_channels(&action.public_channels)?; - let pressure_summary = action.pressure_additions.join("; "); let gestalt = campaign .gestalts .get_mut(&action.gestalt_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented a gestalt".into()))?; + crate::resolution::validate_gestalt_pressure_transition( + &gestalt.pressures, + &action.pressure_additions, + &action.pressure_resolutions, + ) + .map_err(|error| KernelError::Invalid(error.to_string()))?; + let mut summary_parts = Vec::new(); + if !action.pressure_resolutions.is_empty() { + summary_parts.push(format!( + "resolves pressure: {}", + action.pressure_resolutions.join("; ") + )); + } + if !action.pressure_additions.is_empty() { + summary_parts.push(format!( + "takes on pressure: {}", + action.pressure_additions.join("; ") + )); + } + let resolved = action.pressure_resolutions.iter().collect::>(); + gestalt + .pressures + .retain(|pressure| !resolved.contains(pressure)); for pressure in action.pressure_additions { - if !gestalt.pressures.contains(&pressure) { - gestalt.pressures.push(pressure); - } + gestalt.pressures.push(pressure); } gestalt.version += 1; events.push(crate::domain::Event { id: format!("strategic:{revision}:gestalt:{}", gestalt.id), at, kind: "gestalt_action".into(), - summary: format!( - "{} changes its collective situation: {}", - gestalt.name, pressure_summary - ), + summary: format!("{} {}", gestalt.name, summary_parts.join("; ")), actor_ids: vec![], institution_ids: vec![], location_ids: vec![gestalt.home_location_id.clone()], @@ -1997,6 +2008,35 @@ mod tests { assert_eq!(value.gestalt_members, before.gestalt_members); } + #[test] + fn gestalt_action_resolves_exact_pressure_and_records_only_the_typed_transition() { + let mut value = hierarchical_refugee_campaign(); + value.gestalts.get_mut("refugees-east").unwrap().pressures = + vec!["the storm closes the camp".into()]; + let events = apply_strategic_tick_plan( + &mut value, + StrategicTickPlan { + gestalt_actions: vec![StrategicGestaltAction { + gestalt_id: "refugees-east".into(), + pressure_additions: vec!["shelter assignments remain unsettled".into()], + pressure_resolutions: vec!["the storm closes the camp".into()], + public_channels: vec![], + }], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + value.gestalts["refugees-east"].pressures, + vec!["shelter assignments remain unsettled"] + ); + assert_eq!(value.gestalts["refugees-east"].version, 1); + assert_eq!( + events[0].summary, + "Eastern transit refugees resolves pressure: the storm closes the camp; takes on pressure: shelter assignments remain unsettled" + ); + } + #[tokio::test] async fn stale_command_cannot_mutate_campaign() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 5b19167..8404e9d 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -136,7 +136,7 @@ const CELL_APPRAISAL_OUTPUT_CONTRACT: &str = r#"{ "state_references":{"type":"array","items":{"type":"string"}},"public_channels":{"type":"array","items":{"type":"string"}}, "effect":{"oneOf":[ {"type":"object","required":["type","institution_id","posture","location_ids"],"properties":{"type":{"const":"institution"},"institution_id":{"type":"string"},"posture":{"type":"string"},"location_ids":{"type":"array","items":{"type":"string"}}}}, - {"type":"object","required":["type","gestalt_id","pressure_additions"],"properties":{"type":{"const":"gestalt"},"gestalt_id":{"type":"string"},"pressure_additions":{"type":"array","minItems":1,"maxItems":4,"items":{"type":"string"}}}}, + {"type":"object","required":["type","gestalt_id","pressure_additions","pressure_resolutions"],"properties":{"type":{"const":"gestalt"},"gestalt_id":{"type":"string"},"pressure_additions":{"type":"array","maxItems":4,"items":{"type":"string"}},"pressure_resolutions":{"type":"array","maxItems":4,"items":{"type":"string"}}}}, {"type":"object","required":["type","actor_id","destination_id"],"properties":{"type":{"const":"actor_move"},"actor_id":{"type":"string"},"destination_id":{"type":"string"}}}, {"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"member_migration"},"destination_gestalt_id":{"type":"string"}}} ]} @@ -371,6 +371,7 @@ fn cell_interpreter_context(slice: &PermittedCellSlice) -> serde_json::Value { "allowed_public_channels": subject.information_channels, "permitted_state_references": subject.permitted_state_references, "reachable_destination_ids": subject.reachable_destination_ids, + "current_pressures": subject.pressures, })).collect::>(), "member_permissions": slice.member_exceptions.iter().map(|member| serde_json::json!({ "subject_id": member.subject_id, @@ -500,7 +501,7 @@ impl CellProjectionEngine { constrain_cell_proposal_schema(&mut schema, &slice)?; let interpreter_context = serde_json::to_string(&cell_interpreter_context(&slice))?; let permission_guidance = format!( - "Emit at most {} exact constituent- or named-member-attributed attempts. Copy each subject's allowed_effect_type: institution -> institution, gestalt -> gestalt, actor -> actor_move, named member -> member_migration. Use only that subject's permitted_state_references and allowed_public_channels; facet labels are not public channels. A named member uses one supplied migration destination; a population or arena cannot migrate a person. The runtime binds cell identity, revisions, membership, and effective state. The cell id is not an actor id. Use an empty actions array plus a concrete inaction_reason when nobody acts.", + "Emit at most {} exact constituent- or named-member-attributed attempts. Copy each subject's allowed_effect_type: institution -> institution, gestalt -> gestalt, actor -> actor_move, named member -> member_migration. Institution posture must be a specific new commitment or withholding, never the current posture or a generic label. Gestalt pressure_resolutions must copy exact current_pressures that the attempted action resolves; pressure_additions are only new unresolved constraints, threats, obligations, or conditions, never completed actions. At least one and at most four total pressure changes are required. Use only that subject's permitted_state_references and allowed_public_channels; facet labels are not public channels. A named member uses one supplied migration destination; a population or arena cannot migrate a person. The runtime binds cell identity, revisions, membership, and effective state. The cell id is not an actor id. Use an empty actions array plus a concrete inaction_reason when nobody acts.", slice.max_actions ); let mut request = ModelStageRequest { @@ -650,20 +651,24 @@ fn validate_cell_appraisal( location_ids, } if subject.subject_kind == crate::domain::AgencySubjectKind::Institution && institution_id == &subject.subject_id - && !posture.trim().is_empty() + && subject.pressures.first().is_some_and(|current| { + crate::resolution::substantive_text_change(current, posture) + }) && location_ids .iter() .all(|location| subject.location_ids.contains(location)) => {} crate::domain::StrategicCellEffect::Gestalt { gestalt_id, pressure_additions, + pressure_resolutions, } if subject.subject_kind == crate::domain::AgencySubjectKind::Gestalt && gestalt_id == &subject.subject_id - && !pressure_additions.is_empty() - && pressure_additions.len() <= 4 - && pressure_additions - .iter() - .all(|pressure| !pressure.trim().is_empty() && pressure.len() <= 240) => {} + && crate::resolution::validate_gestalt_pressure_transition( + &subject.pressures, + pressure_additions, + pressure_resolutions, + ) + .is_ok() => {} crate::domain::StrategicCellEffect::ActorMove { actor_id, destination_id, @@ -973,6 +978,7 @@ mod tests { effect: StrategicCellEffect::Gestalt { gestalt_id: "crowd".into(), pressure_additions: vec![], + pressure_resolutions: vec![], }, }], inaction_reason: None, diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index 6f8f3f8..dcda819 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -1636,9 +1636,11 @@ pub fn validate_and_resolve_wave( 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::ActorMove { @@ -1720,11 +1722,14 @@ fn validate_cell_proposal( 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)) @@ -1738,14 +1743,16 @@ fn validate_cell_proposal( StrategicCellEffect::Gestalt { gestalt_id, pressure_additions, + pressure_resolutions, } => { if gestalt_id != &proposal.subject_id || !campaign.gestalts.contains_key(gestalt_id) - || pressure_additions.is_empty() - || pressure_additions.len() > 4 - || pressure_additions - .iter() - .any(|pressure| pressure.trim().is_empty() || pressure.len() > 240) + || validate_gestalt_pressure_transition( + &campaign.gestalts[gestalt_id].pressures, + pressure_additions, + pressure_resolutions, + ) + .is_err() { return Err(anyhow!("gestalt proposal exceeds constituent state")); } @@ -1781,6 +1788,56 @@ fn validate_cell_proposal( Ok(()) } +pub fn substantive_text_change(current: &str, candidate: &str) -> bool { + let current = current.trim(); + let candidate = candidate.trim(); + !candidate.is_empty() && candidate.len() <= 240 && !current.eq_ignore_ascii_case(candidate) +} + +pub fn validate_gestalt_pressure_transition( + current: &[String], + additions: &[String], + resolutions: &[String], +) -> Result<()> { + if additions.len() + resolutions.len() == 0 || additions.len() + resolutions.len() > 4 { + return Err(anyhow!( + "gestalt pressure transition must change one to four markers" + )); + } + let clean = |value: &String| { + !value.is_empty() && value.len() <= 240 && value.trim().len() == value.len() + }; + if additions.iter().any(|value| !clean(value)) || resolutions.iter().any(|value| !clean(value)) + { + return Err(anyhow!( + "gestalt pressure markers must be clean bounded text" + )); + } + let normalized_additions = additions + .iter() + .map(|value| value.to_lowercase()) + .collect::>(); + let normalized_resolutions = resolutions + .iter() + .map(|value| value.to_lowercase()) + .collect::>(); + let normalized_current = current + .iter() + .map(|value| value.to_lowercase()) + .collect::>(); + if normalized_additions.len() != additions.len() + || normalized_resolutions.len() != resolutions.len() + || !normalized_additions.is_disjoint(&normalized_resolutions) + || !normalized_additions.is_disjoint(&normalized_current) + || resolutions.iter().any(|value| !current.contains(value)) + { + return Err(anyhow!( + "gestalt pressure transition repeats, overlaps, or invents a resolved pressure" + )); + } + Ok(()) +} + fn validate_member_cell_proposal( campaign: &Campaign, cell: &SimulationCell, @@ -2525,6 +2582,44 @@ pub(crate) mod tests { assert_eq!(value, before); } + #[test] + fn pressure_transition_rejects_noops_and_invented_resolutions() { + let current = vec!["the ferry deadline is near".into()]; + assert!( + validate_gestalt_pressure_transition( + ¤t, + &["shelter assignments are disputed".into()], + &["the ferry deadline is near".into()], + ) + .is_ok() + ); + assert!(validate_gestalt_pressure_transition(¤t, &[], &[]).is_err()); + assert!( + validate_gestalt_pressure_transition( + ¤t, + &["THE FERRY DEADLINE IS NEAR".into()], + &[], + ) + .is_err() + ); + assert!( + validate_gestalt_pressure_transition( + ¤t, + &[], + &["an invented solved problem".into()], + ) + .is_err() + ); + assert!(!substantive_text_change( + "holding position", + "Holding Position" + )); + assert!(substantive_text_change( + "holding position", + "releasing the reserve" + )); + } + #[test] fn partitions_one_thousand_subjects_with_bounded_local_work() { let value = campaign(1_000, 8); diff --git a/docs/architecture/ghostlight-multiresolution-agency.md b/docs/architecture/ghostlight-multiresolution-agency.md index accc349..37df65e 100644 --- a/docs/architecture/ghostlight-multiresolution-agency.md +++ b/docs/architecture/ghostlight-multiresolution-agency.md @@ -250,6 +250,16 @@ knowledge is never treated as an information channel; while dematerialized, the member can publish only through channels owned by their current leaf gestalt's agency profile. +A committed strategic effect must change canonical state. Institutions cannot +re-adopt their current posture. Population effects carry an explicit pressure +lifecycle: `pressure_resolutions` must name exact current pressure markers, and +`pressure_additions` must introduce genuinely new unresolved constraints, +threats, obligations, or conditions. Completed action prose is not a pressure. +The shared transition validator runs in the Interpreter membrane, wave +resolver, and kernel; repeated busywork and invented resolutions therefore +fail before mutation rather than generating another event that says nothing +happened. + ## Public state and controls The schema catalog and CultCache stores publish: