Make gestalt pressure changes consequential

This commit is contained in:
Meta Vens 2026-08-17 22:26:41 +02:00
commit 9a604137dc
6 changed files with 194 additions and 35 deletions

View file

@ -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(),

View file

@ -427,7 +427,10 @@ pub enum StrategicCellEffect {
},
Gestalt {
gestalt_id: String,
#[serde(default)]
pressure_additions: Vec<String>,
#[serde(default)]
pressure_resolutions: Vec<String>,
},
ActorMove {
actor_id: String,
@ -591,6 +594,7 @@ pub struct StrategicInstitutionAction {
pub struct StrategicGestaltAction {
pub gestalt_id: String,
pub pressure_additions: Vec<String>,
pub pressure_resolutions: Vec<String>,
pub public_channels: Vec<String>,
}

View file

@ -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::<BTreeSet<_>>();
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();

View file

@ -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::<Vec<_>>(),
"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,

View file

@ -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::<BTreeSet<_>>();
let normalized_resolutions = resolutions
.iter()
.map(|value| value.to_lowercase())
.collect::<BTreeSet<_>>();
let normalized_current = current
.iter()
.map(|value| value.to_lowercase())
.collect::<BTreeSet<_>>();
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(
&current,
&["shelter assignments are disputed".into()],
&["the ferry deadline is near".into()],
)
.is_ok()
);
assert!(validate_gestalt_pressure_transition(&current, &[], &[]).is_err());
assert!(
validate_gestalt_pressure_transition(
&current,
&["THE FERRY DEADLINE IS NEAR".into()],
&[],
)
.is_err()
);
assert!(
validate_gestalt_pressure_transition(
&current,
&[],
&["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);

View file

@ -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: