Make gestalt pressure changes consequential
This commit is contained in:
parent
673f7a2758
commit
9a604137dc
6 changed files with 194 additions and 35 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
¤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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue